This project has three parts. Part A forecasts daily cash withdrawals from four ATM machines for May 2010. Part B forecasts monthly residential power consumption for 2014. Part C, the bonus, takes two water flow data sets recorded on different time stamps, aggregates them to hourly means, tests whether they are stationary, and forecasts a week forward.
Each part ends with a forecast written to an Excel readable file, and
the whole report is published to RPubs with the .Rmd
submitted alongside it.
Unlike the homework assignments, the data here does not come from a
package. Four Excel files are required and they must sit in a
data/ folder next to this .Rmd:
ATM624Data.xlsxResidentialCustomerForecastLoad-624.xlsxWaterflow_Pipe1.xlsxWaterflow_Pipe2.xlsxThree packages are used directly. fpp3 provides the
tsibble, feasts and fable functions that do all of the modelling.
readxl reads the Excel files in. writexl
writes the forecasts back out to Excel without needing a Java
installation.
A fourth package, urca, must be installed even though it
is never attached. feasts calls it for the unit root tests,
and ARIMA() uses those tests internally when it selects
orders automatically. Without urca present every
ARIMA() call returns a NULL model with only a warning
rather than an error, which is easy to mistake for a modelling problem.
install.packages("urca") fixes it.
Before starting I want to state the approach I follow in all three parts, because it is the same one each time and I would rather explain it once.
I plot the series first and let the plot tell me what structure is there. Then I deal with missing values and outliers explicitly rather than letting a model silently absorb them, because an outlier left in place will widen every prediction interval that follows it. Then I fit several candidate models rather than one, and I choose between them on a test set that the models never saw during fitting. Finally I check the residuals of the winner, because a model that leaves visible structure in its residuals is still leaving information on the table.
The one thing I deliberately do not do is pick a model on training accuracy. A model always fits the data it was fitted to better than it fits new data, so a training number measures memory, not forecasting skill.
The file holds a single long table: one row per ATM per day, with
Cash in hundreds of dollars. The dates arrive as Excel
serial numbers so they need converting, and Excel’s day zero is
1899-12-30.
atm_raw <- read_excel("data/ATM624Data.xlsx")
atm_raw$DATE <- as.Date(atm_raw$DATE, origin = "1899-12-30")
glimpse(atm_raw)
## Rows: 1,474
## Columns: 3
## $ DATE <date> 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, …
Two different kinds of missingness are present and they mean different things.
# Rows with no ATM label at all
atm_raw |> filter(is.na(ATM)) |> summarise(n = n(),
from = min(DATE), to = max(DATE))
## # A tibble: 1 × 3
## n from to
## <int> <date> <date>
## 1 14 2010-05-01 2010-05-14
# Rows that name an ATM but have no Cash value
atm_raw |> filter(!is.na(ATM), is.na(Cash))
## # A tibble: 5 × 3
## DATE ATM Cash
## <date> <chr> <dbl>
## 1 2009-06-13 ATM1 NA
## 2 2009-06-16 ATM1 NA
## 3 2009-06-18 ATM2 NA
## 4 2009-06-22 ATM1 NA
## 5 2009-06-24 ATM2 NA
The fourteen rows with no ATM label are all dated May 2010. They are
placeholder rows for the period I am being asked to forecast, so they
carry no information and I drop them. The five rows that name an ATM but
have no Cash are genuine gaps in the recorded history and I
will fill them.
atm <- atm_raw |>
filter(!is.na(ATM)) |>
as_tsibble(index = DATE, key = ATM)
atm |> as_tibble() |> group_by(ATM) |>
summarise(n = n(), first = min(DATE), last = max(DATE),
missing = sum(is.na(Cash)),
mean = round(mean(Cash, na.rm = TRUE), 1),
median = round(median(Cash, na.rm = TRUE), 1),
max = round(max(Cash, na.rm = TRUE), 1))
## # A tibble: 4 × 8
## ATM n first last missing mean median max
## <chr> <int> <date> <date> <int> <dbl> <dbl> <dbl>
## 1 ATM1 365 2009-05-01 2010-04-30 3 83.9 91 180
## 2 ATM2 365 2009-05-01 2010-04-30 2 62.6 67 147
## 3 ATM3 365 2009-05-01 2010-04-30 0 0.7 0 96
## 4 ATM4 365 2009-05-01 2010-04-30 0 474 404. 10920.
Each machine has exactly 365 daily observations running from 1 May 2009 to 30 April 2010, so the series are complete in time and I am forecasting the 31 days of May 2010. The summary already shows that the four machines are not alike. ATM4 averages 474 hundred dollars against ATM1’s 84, and its maximum of 10,920 is more than twenty times its own median. ATM3 averages 0.72, which is close enough to zero to be worth a separate look.
atm |>
autoplot(Cash) +
facet_wrap(~ATM, scales = "free_y", ncol = 1) +
labs(title = "Daily cash withdrawals by machine",
y = "Cash (hundreds of dollars)", x = NULL) +
theme(legend.position = "none")
The plot settles most of the questions.
ATM1 and ATM2 are well behaved. Both fluctuate around a stable level with a dense high frequency wobble that is almost certainly a weekly cycle.
ATM3 is a flat line at zero until the very end, where three non-zero values appear.
ATM4 has one spike so large that it flattens everything else in its panel into a band near the bottom.
atm |> filter(ATM == "ATM3", 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
ATM3 has three non-zero days in its entire history, and they are the last three days of the record: 28, 29 and 30 April 2010. This is not a machine with missing data or a broken sensor. This is a machine that was switched on at the end of April 2010 and has been running for three days.
That distinction drives the whole treatment. If I feed 362 zeros and 3 real values into any seasonal model, the model will conclude the series is essentially zero and forecast close to zero for May. That forecast would be confidently wrong, because the machine is demonstrably now dispensing cash at a rate comparable to ATM1. The three real observations are the only relevant data, and the honest forecast is their mean, carried forward with a wide interval that admits how little I know. I return to this after handling the other three machines.
atm |> filter(ATM == "ATM4") |> as_tibble() |>
slice_max(Cash, n = 5) |> arrange(desc(Cash))
## # A tibble: 5 × 3
## 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.
A single day, 9 February 2010, records 10,919.76 hundred dollars, which is roughly $1.09 million out of one ATM in one day. The next largest day in the whole year is 1,712, and the median is 404. This is not a busy day, it is a data error or a one-off event such as a bulk reload, and either way it will not repeat in May.
Leaving it in would drag the level of any fitted model upward and inflate the residual variance, which widens every prediction interval. I replace it with the series median, which is a conservative choice because it does not assume the day was anything special.
atm <- atm |>
mutate(Cash = if_else(ATM == "ATM4" & Cash > 5000,
median(atm$Cash[atm$ATM == "ATM4"], na.rm = TRUE),
Cash))
The five gaps sit in ATM1 and ATM2 in June 2009. Because I have already seen a weekly rhythm, replacing a missing Wednesday with the overall series mean would distort it. I fill each gap with the median of that machine on that same day of the week, which respects the weekly structure.
atm <- atm |>
mutate(dow = wday(DATE, label = TRUE)) |>
group_by(ATM, dow) |>
mutate(Cash = if_else(is.na(Cash), median(Cash, na.rm = TRUE), Cash)) |>
ungroup() |>
select(-dow)
sum(is.na(atm$Cash))
## [1] 0
No missing values remain.
atm |>
filter(ATM != "ATM3") |>
gg_season(Cash, period = "week") +
facet_wrap(~ATM, scales = "free_y", ncol = 1) +
labs(title = "Weekly seasonal pattern", y = "Cash (hundreds)")
atm |> as_tibble() |>
filter(ATM != "ATM3") |>
mutate(dow = wday(DATE, label = TRUE)) |>
group_by(ATM, dow) |>
summarise(mean = round(mean(Cash), 1), .groups = "drop") |>
pivot_wider(names_from = ATM, values_from = mean)
## # A tibble: 7 × 4
## dow ATM1 ATM2 ATM4
## <ord> <dbl> <dbl> <dbl>
## 1 Sun 103. 67.2 539.
## 2 Mon 86 58.7 481.
## 3 Tue 89.8 73.2 445
## 4 Wed 82.2 43.4 414.
## 5 Thu 31.7 25.2 170
## 6 Fri 98.6 92 574.
## 7 Sat 96.6 76 492.
The weekly pattern is real and it is the same shape on all three machines: averaged over the whole year, Thursday is far lower than every other day, at roughly a third of the level of the surrounding days. Whatever causes it is a property of the location rather than the machine. A model for these series has to carry a period-7 season.
A year-long average can hide a pattern that changes, so before relying on it I check which day is lowest month by month, and compare the most recent eight weeks with the rest of the year. This matters for forecasting: a model that adapts its seasonal shape will follow the recent pattern, while one that averages over the whole year will not.
low_by_month <- atm |> as_tibble() |>
filter(ATM == "ATM1") |>
mutate(month = yearmonth(DATE), dow = wday(DATE, label = TRUE, abbr = FALSE)) |>
group_by(month, dow) |>
summarise(mean = mean(Cash), .groups = "drop") |>
group_by(month) |>
slice_min(mean, n = 1, with_ties = FALSE) |>
ungroup()
low_by_month
## # A tibble: 12 × 3
## month dow mean
## <mth> <ord> <dbl>
## 1 2009 May Thursday 11
## 2 2009 Jun Thursday 16
## 3 2009 Jul Thursday 13.6
## 4 2009 Aug Thursday 21
## 5 2009 Sep Thursday 14.2
## 6 2009 Oct Thursday 10.8
## 7 2009 Nov Thursday 16
## 8 2009 Dec Thursday 12.4
## 9 2010 Jan Thursday 19.2
## 10 2010 Feb Monday 74.2
## 11 2010 Mar Tuesday 6.8
## 12 2010 Apr Tuesday 3.5
low_recent_vs_earlier <- atm |> as_tibble() |>
filter(ATM != "ATM3") |>
mutate(dow = wday(DATE, label = TRUE, abbr = FALSE),
period = if_else(DATE > max(DATE) - 56, "Last 8 weeks", "Earlier")) |>
group_by(ATM, period, dow) |>
summarise(mean = round(mean(Cash), 1), .groups = "drop") |>
group_by(ATM, period) |>
slice_min(mean, n = 1, with_ties = FALSE) |>
ungroup()
low_recent_vs_earlier
## # A tibble: 6 × 4
## ATM period dow mean
## <chr> <chr> <ord> <dbl>
## 1 ATM1 Earlier Thursday 22
## 2 ATM1 Last 8 weeks Tuesday 5.5
## 3 ATM2 Earlier Thursday 13.2
## 4 ATM2 Last 8 weeks Tuesday 1.2
## 5 ATM4 Earlier Thursday 120.
## 6 ATM4 Last 8 weeks Tuesday 21.3
low_earlier <- low_recent_vs_earlier |>
filter(ATM == "ATM1", period == "Earlier") |> pull(dow) |> as.character()
low_recent <- low_recent_vs_earlier |>
filter(ATM == "ATM1", period == "Last 8 weeks") |> pull(dow) |> as.character()
For ATM1, the lowest day over the earlier part of the year is Thursday and the lowest day over the last eight weeks is Tuesday, and the same switch shows up on ATM2 and ATM4. The month by month table dates the change: Thursday is the low day in every month from May 2009 to January 2010, February 2010 has no clearly low day, and from March 2010 onward the dip falls on Tuesday. The low day has moved. That is an important finding for the forecast: May will follow the recent pattern, so I want models that let the seasonal shape adapt over time (ETS with a seasonal smoothing parameter, or STL with a finite seasonal window) rather than ones that fix a single shape from the whole year.
atm |> filter(ATM != "ATM3") |> features(Cash, features = guerrero)
## # A tibble: 3 × 2
## ATM lambda_guerrero
## <chr> <dbl>
## 1 ATM1 0.249
## 2 ATM2 0.673
## 3 ATM4 0.453
The Guerrero values come out at about 0.25 for ATM1, 0.67 for ATM2 and 0.45 for ATM4. All three sit between 0 and 1, which says the variance does grow somewhat with the level, but none is close enough to 0 to make a log transformation the obvious move.
I choose not to transform these series, for two reasons. ATM2 contains an exact zero, which rules out a log and makes small lambda Box-Cox values awkward. More importantly, ETS can handle growing variance directly through a multiplicative error term, so the structure the Guerrero value is pointing at can be captured inside the model rather than by reshaping the data first. That keeps the forecasts on the original scale, in hundreds of dollars, which matters for a figure a business reader has to act on.
atm |> filter(ATM != "ATM3") |>
features(Cash, unitroot_nsdiffs)
## # A tibble: 3 × 2
## ATM nsdiffs
## <chr> <int>
## 1 ATM1 1
## 2 ATM2 1
## 3 ATM4 0
atm |> filter(ATM != "ATM3") |>
features(Cash, unitroot_ndiffs)
## # A tibble: 3 × 2
## ATM ndiffs
## <chr> <int>
## 1 ATM1 1
## 2 ATM2 1
## 3 ATM4 0
The two tests split the machines. ATM1 and ATM2 each want one seasonal difference and one first difference, while ATM4 wants neither.
That difference is informative rather than awkward. ATM1 and ATM2 have enough persistence in their weekly pattern that differencing helps; ATM4, once its outlier is gone, is already stationary around a fixed level. It is another sign that ATM4 is the least structured of the three, which the accuracy table below confirms. I do not difference by hand: ARIMA applies whatever differencing it needs internally, and these tests simply tell me what to expect it to choose.
I hold back the last 28 days, four complete weeks, as a test set. Four weeks is long enough to expose a model that has failed to learn the weekly cycle and short enough to leave nearly a year for training.
I fit four candidates. Seasonal naive is the benchmark, and it is a serious one here because a series with a strong weekly cycle and no trend is exactly what it is built for, and because it simply repeats the most recent week it automatically picks up the move of the low day to Tuesday. ETS and ARIMA are chosen automatically. STL decomposition with an ETS model on the seasonally adjusted series is included because its default seasonal window lets the weekly shape change slowly over the year, which suits a pattern that has shifted.
atm_model <- atm |> filter(ATM != "ATM3")
train <- atm_model |> filter(DATE <= as.Date("2010-04-02"))
fit <- train |>
model(
snaive = SNAIVE(Cash),
ets = ETS(Cash),
arima = ARIMA(Cash),
stl = decomposition_model(STL(Cash ~ season(period = 7)),
ETS(season_adjust ~ season("N")))
)
fc_test <- fit |> forecast(h = 28)
accuracy(fc_test, atm_model) |>
select(ATM, .model, RMSE, MAE, MAPE, MASE) |>
arrange(ATM, RMSE)
## # A tibble: 12 × 6
## ATM .model RMSE MAE MAPE MASE
## <chr> <chr> <dbl> <dbl> <dbl> <dbl>
## 1 ATM1 ets 12.4 10.0 70.8 0.544
## 2 ATM1 stl 12.6 10.1 82.1 0.550
## 3 ATM1 arima 13.0 10.5 93.1 0.570
## 4 ATM1 snaive 16.9 14.7 79.2 0.799
## 5 ATM2 stl 18.9 13.7 102. 0.660
## 6 ATM2 ets 19.8 14.2 56.5 0.681
## 7 ATM2 arima 21.7 16.9 51.2 0.812
## 8 ATM2 snaive 28.6 18.6 46.8 0.896
## 9 ATM4 stl 245. 204. 595. 0.579
## 10 ATM4 snaive 272. 196. 364. 0.556
## 11 ATM4 arima 281. 240. 1115. 0.680
## 12 ATM4 ets 313. 263. 1349. 0.747
Which measure I use, and which I ignore. I select on RMSE, because it penalises large misses more heavily than small ones, and a day when the machine runs out of cash is the costly mistake for a bank. I read MAE and MASE alongside it as checks. I deliberately ignore MAPE here. MAPE divides each error by the actual value, and these series have days with withdrawals close to zero, so a miss of a few hundred dollars on a near-zero day becomes an enormous percentage. That is why the MAPE column runs from about 50 percent to over 1,300 percent: it is measuring how close the actual values get to zero, not how good the forecasts are.
I also want to be precise about MASE, because it is easy to misread.
MASE divides each model’s test error by the average error that a
seasonal naive forecast made in-sample, one step ahead. A MASE
below 1 therefore means the model’s errors over the four week test are
smaller than seasonal naive’s one-step errors on the training data. It
does not by itself mean the model beat seasonal naive
on the test set. To answer that question I compare against the
snaive row in the same table, which has its own MASE (0.80
on ATM1, for example).
ATM1. ETS is best on RMSE (12.4), MAE and MASE, with STL a close second and ARIMA third. All three beat seasonal naive (RMSE 16.9) clearly. ETS is the choice.
ATM2. STL is best on RMSE (18.9), MAE and MASE, with ETS second. Again all three beat seasonal naive (28.6) clearly. STL is the choice.
ATM4. This is the machine where the measures disagree, and the choice needs arguing rather than reading off. STL has the lowest RMSE (245 against 272 for seasonal naive), but seasonal naive has the lowest MAE (196 against 204) and the lowest MASE (0.556 against 0.579). ETS, which won on ATM1, comes last here on every measure, and ARIMA is third.
Read together, these numbers say STL and seasonal naive are about equally good on a typical day, and STL is better at avoiding the occasional large miss, which is exactly what RMSE rewards and MAE does not. I select STL for ATM4 on that basis, consistent with the RMSE rule I set out above, but the honest summary is that ATM4 carries little exploitable structure beyond its weekly cycle, and a forecast that simply repeats the last week would be nearly as good. The RMSE values for ATM4 are an order of magnitude larger than for the other machines only because the series itself is an order of magnitude larger.
I select the best model for each machine separately rather than forcing one model across all three, because the machines are genuinely different series and the table shows no single specification winning everywhere: ETS is first on ATM1 and last on ATM4.
A limitation of this comparison. The ranking rests on one four week window. A different four weeks could reorder close pairs such as ETS and STL on ATM1. Time series cross-validation, which repeats the comparison over many rolling windows, would give a more stable ranking; I did not do it here because it multiplies the fitting time by the number of windows, and on ATM1 and ATM2 the winners beat the benchmark by a wide margin that one window is enough to establish.
best <- accuracy(fc_test, atm_model) |>
group_by(ATM) |>
slice_min(RMSE, n = 1) |>
ungroup() |>
select(ATM, .model)
best
## # A tibble: 3 × 2
## ATM .model
## <chr> <chr>
## 1 ATM1 ets
## 2 ATM2 stl
## 3 ATM4 stl
final_fit <- atm_model |>
model(
snaive = SNAIVE(Cash),
ets = ETS(Cash),
arima = ARIMA(Cash),
stl = decomposition_model(STL(Cash ~ season(period = 7)),
ETS(season_adjust ~ season("N")))
)
for (m in unique(best$ATM)) {
chosen <- best$.model[best$ATM == m]
print(final_fit |> filter(ATM == m) |> select(all_of(chosen)) |>
gg_tsresiduals() + labs(title = paste(m, "-", chosen)))
}
final_fit |>
augment() |>
features(.innov, ljung_box, lag = 14, dof = 0) |>
inner_join(best, by = c("ATM", ".model"))
## # A tibble: 3 × 4
## ATM .model lb_stat lb_pvalue
## <chr> <chr> <dbl> <dbl>
## 1 ATM1 ets 28.8 0.0112
## 2 ATM2 stl 22.5 0.0683
## 3 ATM4 stl 46.4 0.0000245
The residual plots show series centred on zero with no remaining trend or seasonal shape, and the autocorrelation functions are mostly inside the significance bounds.
The Ljung-Box results are more mixed and I would rather report them plainly than gloss over them. ATM2 passes at the 5 percent level with a p-value near 0.07. ATM1 fails marginally at about 0.01. ATM4 fails decisively, with a p-value near 0.00002.
This means the residuals are not pure white noise, so some structure remains unmodelled. The practical consequence is specific and worth stating precisely: the point forecasts are still usable, because the test set comparison already showed these models matching or beating the benchmark on data they had not seen, but the prediction intervals are too narrow. Real uncertainty is wider than the intervals imply, especially for ATM4.
I chose not to chase the remaining structure with a more elaborate model. Daily cash withdrawal is driven by paydays, weekends and local events that no purely univariate model can see, so some autocorrelation in the residuals is expected here rather than a sign of a fixable error. The right response for a business forecast one month ahead is to treat the intervals as a lower bound on uncertainty, which is what I recommend in the conclusion.
fc_atm <- final_fit |>
forecast(h = 31) |>
inner_join(best, by = c("ATM", ".model"))
fc_atm |>
autoplot(atm_model |> filter(DATE >= as.Date("2010-01-01")), level = 80) +
facet_wrap(~ATM, scales = "free_y", ncol = 1) +
labs(title = "May 2010 forecasts, ATM1, ATM2 and ATM4",
y = "Cash (hundreds of dollars)", x = NULL)
fc_low <- fc_atm |>
as_tibble() |>
mutate(dow = wday(DATE, label = TRUE, abbr = FALSE)) |>
group_by(ATM, dow) |>
summarise(mean_forecast = round(mean(.mean), 1), .groups = "drop") |>
group_by(ATM) |>
slice_min(mean_forecast, n = 1, with_ties = FALSE) |>
ungroup()
fc_low
## # A tibble: 3 × 3
## ATM dow mean_forecast
## <chr> <ord> <dbl>
## 1 ATM1 Tuesday 5.7
## 2 ATM2 Tuesday 1.8
## 3 ATM4 Tuesday 64.4
fc_low_day <- fc_low |> filter(ATM == "ATM1") |> pull(dow) |> as.character()
The forecasts carry the weekly cycle forward, and the intervals widen only gently because the series have no trend to extrapolate. The low day in the May forecast is Tuesday. That matches the recent pattern found earlier rather than the year-long average, which is what an adaptive model should do: the low day moved from Thursday to Tuesday in the final weeks of the data, and the selected models have followed it. A seasonal naive forecast would also have repeated the recent week, but a model that fixed one seasonal shape from the whole year would have put the dip on the wrong day for all of May.
Now ATM3, handled separately for the reasons given earlier.
atm3_vals <- atm |> filter(ATM == "ATM3", Cash > 0) |> pull(Cash)
atm3_mean <- mean(atm3_vals)
atm3_sd <- sd(atm3_vals)
n3 <- length(atm3_vals)
# Option 1: interval from ATM3's own three values. A t multiplier with
# n - 1 = 2 degrees of freedom replaces 1.28, and sqrt(1 + 1/n) adds the
# uncertainty in the mean itself to the day-to-day variation.
half_own <- qt(0.90, df = n3 - 1) * atm3_sd * sqrt(1 + 1 / n3)
# Option 2: borrow day-to-day variability from ATM1, a machine at a similar
# level, by applying its coefficient of variation to ATM3's mean.
atm1_cash <- atm |> filter(ATM == "ATM1") |> pull(Cash)
atm1_cv <- sd(atm1_cash) / mean(atm1_cash)
half_borrow <- qnorm(0.90) * atm1_cv * atm3_mean
half_used <- max(half_own, half_borrow)
c(mean = atm3_mean, sd_3_days = atm3_sd,
half_width_own = half_own, half_width_borrowed = half_borrow)
## mean sd_3_days half_width_own half_width_borrowed
## 87.666667 7.371115 16.049307 48.864929
atm3_fc <- tibble(
DATE = seq(as.Date("2010-05-01"), as.Date("2010-05-31"), by = "day"),
ATM = "ATM3",
.mean = atm3_mean,
lower80 = pmax(0, atm3_mean - half_used),
upper80 = atm3_mean + half_used
)
head(atm3_fc, 3)
## # A tibble: 3 × 5
## DATE ATM .mean lower80 upper80
## <date> <chr> <dbl> <dbl> <dbl>
## 1 2010-05-01 ATM3 87.7 38.8 137.
## 2 2010-05-02 ATM3 87.7 38.8 137.
## 3 2010-05-03 ATM3 87.7 38.8 137.
With three observations I cannot estimate a weekly season or a trend, so the forecast is a flat line at their mean of about 87.7 hundred dollars.
The interval needs more thought than the point forecast. The three
values happen to be close together (96, 82 and 85), so their standard
deviation is small, about 7.4. Using 1.28 × sd directly
would give an interval of about ±11 percent, narrower than ATM1’s, which
would wrongly suggest I know more about a three day old machine than
about one with a full year of history. A standard deviation estimated
from three numbers is itself very uncertain, and three days cannot show
the weekly swing that every other machine has.
So I compute the half-width two ways and use the wider. The first uses ATM3’s own values with a t multiplier on two degrees of freedom, which allows for the uncertainty in that small-sample standard deviation, giving about ±16. The second borrows the day-to-day variability of ATM1, a machine operating at a similar level, by applying ATM1’s coefficient of variation (about 0.43) to ATM3’s mean, giving about ±48.9. The 80 percent interval I report is 38.8 to 136.5 hundred dollars a day. That width is the correct signal to send: this number rests on three days of evidence and should be revisited as soon as more arrive.
One thing I did not do is give ATM3 a weekly pattern by borrowing ATM1’s seasonal shape. That would be a reasonable refinement, but three days give no evidence that ATM3 shares ATM1’s pattern. Its three days fall on a Wednesday, Thursday and Friday, so they include no Tuesday, the day the other machines now dip, and cannot tell me whether ATM3 dips too. I keep the flat forecast and let the wide interval carry that uncertainty, which is another reason the borrowed, wider interval is the right one to use.
atm_out <- fc_atm |>
hilo(80) |>
as_tibble() |>
mutate(lower80 = pmax(0, `80%`$lower), upper80 = `80%`$upper) |>
select(DATE, ATM, Forecast = .mean, lower80, upper80) |>
bind_rows(atm3_fc |> select(DATE, ATM, Forecast = .mean, lower80, upper80)) |>
arrange(ATM, DATE) |>
mutate(DATE = as.character(DATE),
across(c(Forecast, lower80, upper80), \(x) round(x, 2)))
write_xlsx(atm_out, "ATM_Forecast_May2010.xlsx")
atm_out |> group_by(ATM) |>
summarise(days = n(),
total_hundreds = round(sum(Forecast), 0),
total_dollars = scales::comma(round(sum(Forecast) * 100, 0)))
## # A tibble: 4 × 4
## ATM days total_hundreds total_dollars
## <chr> <int> <dbl> <chr>
## 1 ATM1 31 2388 238,808
## 2 ATM2 31 1773 177,346
## 3 ATM3 31 2718 271,777
## 4 ATM4 31 13744 1,374,442
head(atm_out, 8)
## # A tibble: 8 × 5
## DATE ATM Forecast lower80 upper80
## <chr> <chr> <dbl> <dbl> <dbl>
## 1 2010-05-01 ATM1 87.0 56.3 118.
## 2 2010-05-02 ATM1 101. 70.0 132.
## 3 2010-05-03 ATM1 73.1 42.3 104.
## 4 2010-05-04 ATM1 5.72 0 36.5
## 5 2010-05-05 ATM1 100. 69.3 131.
## 6 2010-05-06 ATM1 79.4 48.6 110.
## 7 2010-05-07 ATM1 85.6 53.0 118.
## 8 2010-05-08 ATM1 87.0 54.5 120.
The file ATM_Forecast_May2010.xlsx contains all 124
rows, 31 days for each of the four machines, with an 80 percent interval
alongside each point forecast. In business terms the four machines
together are forecast to dispense on the order of 2.06 million dollars
over May 2010, with ATM4 accounting for about two thirds of it on its
own.
pwr_raw <- read_excel("data/ResidentialCustomerForecastLoad-624.xlsx")
glimpse(pwr_raw)
## 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…
pwr <- pwr_raw |>
mutate(Month = yearmonth(`YYYY-MMM`)) |>
select(Month, KWH) |>
as_tsibble(index = Month)
pwr |> as_tibble() |>
summarise(n = n(), from = min(Month), to = max(Month),
missing = sum(is.na(KWH)))
## # A tibble: 1 × 4
## n from to missing
## <int> <mth> <mth> <int>
## 1 192 1998 Jan 2013 Dec 1
192 monthly observations from January 1998 to December 2013, exactly the sixteen years described, with one missing value.
pwr |> autoplot(KWH) +
labs(title = "Monthly residential power consumption",
y = "KWH", x = NULL)
Three features stand out. There is a slow upward trend across the sixteen years. There is a strong annual season with two peaks a year, a tall summer peak and a smaller winter one, which is the signature of air conditioning and heating load. And there is one dramatic downward spike near 2010 that breaks the pattern completely.
pwr |> filter(is.na(KWH))
## # A tsibble: 1 x 2 [1M]
## Month KWH
## <mth> <dbl>
## 1 2008 Sep NA
pwr |> as_tibble() |> slice_min(KWH, n = 3)
## # A tibble: 3 × 2
## Month KWH
## <mth> <dbl>
## 1 2010 Jul 770523
## 2 2005 Nov 4313019
## 3 1999 Dec 4419229
September 2008 is missing. July 2010 records 770,523 KWH when neighbouring Julys run between six and ten million. A summer month cannot plausibly be the lowest month in sixteen years of record; July is normally the annual peak. I treat it as a recording error rather than a real collapse in demand.
Both are the same kind of problem, a month whose value I do not trust, so I treat them the same way. I set the July 2010 value to missing and then interpolate both gaps from an ARIMA model fitted to the rest of the series, which fills them consistently with the trend and season around them instead of with a flat average.
pwr_flagged <- pwr |>
mutate(KWH = if_else(Month == yearmonth("2010 Jul"), NA_real_, KWH))
# Orders are stated explicitly here rather than searched: automatic selection
# needs complete data to run its unit root tests, and the whole point of this
# step is that the data is not yet complete. A seasonal AR term with one
# seasonal difference is the right shape for a series like this.
pwr_clean <- pwr_flagged |>
model(ARIMA(KWH ~ pdq(1, 0, 0) + PDQ(1, 1, 0))) |>
interpolate(pwr_flagged)
pwr_clean |> filter(Month %in% c(yearmonth("2008 Sep"), yearmonth("2010 Jul")))
## # A tsibble: 2 x 2 [1M]
## Month KWH
## <mth> <dbl>
## 1 2008 Sep 7472363.
## 2 2010 Jul 8114472.
Both months are now filled. The July 2010 figure of about 8.1 million sits naturally among its neighbouring Julys, which run 7.6 million in 2008 and 7.7 million in 2009, confirming the interpolation is sensible rather than merely non-missing.
pwr_clean |> filter(month(Month) == 7, year(Month) %in% 2008:2012)
## # A tsibble: 5 x 2 [1M]
## Month KWH
## <mth> <dbl>
## 1 2008 Jul 7643987
## 2 2009 Jul 7713260
## 3 2010 Jul 8114472.
## 4 2011 Jul 10093343
## 5 2012 Jul 8886851
pwr_clean |> autoplot(KWH) +
labs(title = "Monthly residential power consumption, cleaned",
y = "KWH", x = NULL)
pwr_clean |>
model(STL(KWH ~ trend(window = 21) + season(window = "periodic"))) |>
components() |>
autoplot() +
labs(title = "STL decomposition of power consumption")
The decomposition separates the three pieces cleanly. The trend rises steadily with a flattening after about 2010. The seasonal component is stable in shape and large in magnitude relative to the remainder, confirming that the annual cycle is the dominant structure. The remainder is small and mostly patternless, which says trend and season together explain most of what happens in this series.
pwr_clean |> features(KWH, features = guerrero)
## # A tibble: 1 × 1
## lambda_guerrero
## <dbl>
## 1 -0.212
The Guerrero method suggests a lambda of about -0.21. Looking back at the series plot, the seasonal swings do grow slightly as the level rises over the sixteen years, so a variance stabilising transformation is justified here in a way it was not for the ATM data.
I use a log transformation, which is Box-Cox with lambda = 0, rather than the suggested -0.21. The two values are close, so they stabilise the variance by almost the same amount, and the Guerrero estimate is itself uncertain at this sample size, so I do not treat -0.21 as more precise than “close to zero”. Given that, I prefer the transformation that is easier to explain: a log models proportional change, so the seasonal swing becomes a percentage of the level rather than a fixed number of kilowatt hours, which is how a business reader would naturally describe it.
fable back transforms the forecasts automatically. It
also bias-adjusts them: the .mean it reports for a log
model is the mean of the back-transformed forecast distribution, not
simply exp() of the forecast on the log scale (which would
be the median and would sit slightly low). The prediction intervals are
percentiles of that same distribution, so no further adjustment is
needed.
pwr_clean |> features(log(KWH), unitroot_nsdiffs)
## # A tibble: 1 × 1
## nsdiffs
## <int>
## 1 1
pwr_clean |> features(log(KWH), unitroot_ndiffs)
## # A tibble: 1 × 1
## ndiffs
## <int>
## 1 1
Both tests return 1: one seasonal difference to remove the annual cycle and one first difference to remove the trend. That matches what the plot and the decomposition already showed, a series with both a season and a trend.
It is worth noting that the ARIMA model selected later does not follow this exactly. It takes the seasonal difference but handles the trend with a drift term instead of a first difference, which is an equivalent way of accounting for steady growth and often a better behaved one for forecasting. These tests guide the search rather than dictate its result.
I hold back the last 24 months, two full years, as a test set. Two years is the right length for a monthly series with an annual cycle: it tests the seasonal pattern twice rather than once.
pwr_train <- pwr_clean |> filter(Month <= yearmonth("2011 Dec"))
pwr_fit <- pwr_train |>
model(
snaive = SNAIVE(KWH),
ets = ETS(log(KWH)),
arima = ARIMA(log(KWH)),
stl = decomposition_model(STL(log(KWH)),
ETS(season_adjust ~ season("N")))
)
pwr_fit
## # A mable: 1 x 4
## snaive ets arima
## <model> <model> <model>
## 1 <SNAIVE> <ETS(A,N,A)> <ARIMA(1,0,1)(2,1,0)[12] w/ drift>
## # ℹ 1 more variable: stl <model>
pwr_fc_test <- pwr_fit |> forecast(h = 24)
accuracy(pwr_fc_test, pwr_clean) |>
select(.model, RMSE, MAE, MAPE, MASE) |>
arrange(RMSE)
## # A tibble: 4 × 5
## .model RMSE MAE MAPE MASE
## <chr> <dbl> <dbl> <dbl> <dbl>
## 1 arima 785721. 626777. 8.15 1.03
## 2 ets 853626. 648947. 8.65 1.07
## 3 stl 882970. 735436. 9.90 1.21
## 4 snaive 965567. 742063. 9.30 1.22
The training-period models chosen automatically were ETS(A,N,A) on the log scale, which is additive level and season with no trend, and ARIMA(1,0,1)(2,1,0)[12] with drift.
ARIMA is best on every measure: RMSE (786 thousand KWH), MAE, MAPE (8.2 percent) and MASE (1.03). ETS is second on every measure. STL is third on RMSE but slightly worse than seasonal naive on MAPE (9.9 against 9.3 percent), so it is not a clear improvement over the benchmark. Seasonal naive is last on RMSE and MASE.
Why I chose ARIMA and not the others. ARIMA wins on all four measures, so the choice does not depend on which measure I prefer. ETS is the runner-up and a reasonable alternative; its weakness here is that it chose no trend term, so it cannot follow the slow rise in consumption, while ARIMA’s drift term can. STL does not reliably beat the benchmark, and seasonal naive, by construction, only repeats last year and so ignores the trend entirely.
Reading MASE correctly. Every MASE here is above 1, including the winner’s 1.03. That does not mean ARIMA failed to beat seasonal naive. MASE divides the test error by seasonal naive’s average in-sample, one-step error, and errors two years ahead are naturally larger than errors one step ahead. The fair comparison is with seasonal naive’s own MASE on the same test window, which is 1.22. ARIMA’s MAE is about 16 percent lower than seasonal naive’s, so it did beat the benchmark.
pwr_best <- accuracy(pwr_fc_test, pwr_clean) |>
slice_min(RMSE, n = 1) |> pull(.model)
pwr_best
## [1] "arima"
pwr_final <- pwr_clean |>
model(
snaive = SNAIVE(KWH),
ets = ETS(log(KWH)),
arima = ARIMA(log(KWH)),
stl = decomposition_model(STL(log(KWH)),
ETS(season_adjust ~ season("N")))
)
pwr_final |> select(all_of(pwr_best)) |> report()
## Series: KWH
## Model: ARIMA(0,0,1)(2,1,0)[12] w/ drift
## Transformation: log(KWH)
##
## Coefficients:
## ma1 sar1 sar2 constant
## 0.2602 -0.7115 -0.3561 0.0322
## s.e. 0.0846 0.0757 0.0776 0.0089
##
## sigma^2 estimated as 0.008457: log likelihood=172.75
## AIC=-335.49 AICc=-335.15 BIC=-319.53
The final model is refitted on all sixteen years, and when
ARIMA() searches again on the full data it settles on a
slightly different order from the one chosen on the training years:
ARIMA(0,0,1)(2,1,0)[12] with drift rather than (1,0,1)(2,1,0)[12]. The
seasonal part, the seasonal difference and the drift are unchanged, and
these carry almost all of the forecast; only the short-run non-seasonal
terms differ. I allow the re-search rather than freezing the training
order, because the extra two years are real information and the test
already established that the ARIMA family is the right choice for this
series.
pwr_final |> select(all_of(pwr_best)) |> gg_tsresiduals() +
labs(title = paste("Residuals,", pwr_best))
pwr_final |> select(all_of(pwr_best)) |> augment() |>
features(.innov, ljung_box, lag = 24, dof = 0)
## # A tibble: 1 × 3
## .model lb_stat lb_pvalue
## <chr> <dbl> <dbl>
## 1 arima 25.7 0.366
The residuals are centred on zero, the histogram is roughly symmetric, and the autocorrelations sit inside the bounds at almost every lag. The Ljung-Box p-value of about 0.37 is comfortably above 0.05, so I fail to reject the hypothesis that the residuals are white noise.
This is a noticeably cleaner result than the ATM models produced, and it means the prediction intervals here can be taken at face value rather than treated as a lower bound. The model has taken the trend and the annual season out of the series and left behind something close to noise, which is exactly the outcome I want before forecasting.
fc_pwr <- pwr_final |> select(all_of(pwr_best)) |> forecast(h = 12)
fc_pwr |>
autoplot(pwr_clean |> filter(Month >= yearmonth("2008 Jan"))) +
labs(title = "2014 monthly power consumption forecast",
y = "KWH", x = NULL)
The forecast reproduces the familiar double peaked annual shape. The two highest months are January at about 10.3 million KWH and August at about 10.1 million, the winter heating peak and the summer cooling peak respectively, with the shoulder months of May and October forming the troughs near 5.9 million. The ratio between peak and trough is close to 1.75, so this is a series where the season matters more than the trend for any single month’s planning.
The 80 percent intervals stay at roughly ±12 percent of the forecast through the whole year. They widen in absolute terms in the high-consumption months simply because the level is higher, but in relative terms they barely grow, because most of the uncertainty a year ahead comes from the month-to-month noise rather than from uncertainty about the trend. Because the model was fitted on a log scale they are asymmetric on the original scale, slightly wider above the forecast than below it. That is the correct behaviour for a quantity that cannot go negative.
pwr_out <- fc_pwr |>
hilo(80) |>
as_tibble() |>
mutate(Month = as.character(Month),
Forecast_KWH = round(.mean, 0),
lower80 = round(`80%`$lower, 0),
upper80 = round(`80%`$upper, 0)) |>
select(Month, Forecast_KWH, lower80, upper80)
write_xlsx(pwr_out, "Power_Forecast_2014.xlsx")
pwr_out
## # A tibble: 12 × 4
## Month Forecast_KWH lower80 upper80
## <chr> <dbl> <dbl> <dbl>
## 1 2014 Jan 10288648 9106320 11526799
## 2 2014 Feb 8500789 7492306 9558514
## 3 2014 Mar 6642050 5854077 7468498
## 4 2014 Apr 5976476 5267462 6720108
## 5 2014 May 5941318 5236475 6680576
## 6 2014 Jun 8261934 7281787 9289938
## 7 2014 Jul 9495766 8369245 10677292
## 8 2014 Aug 10056074 8863081 11307318
## 9 2014 Sep 8453019 7450203 9504799
## 10 2014 Oct 5849270 5155347 6577075
## 11 2014 Nov 6109819 5384986 6870043
## 12 2014 Dec 8273192 7291709 9302597
The total forecast consumption for 2014 is 93,848,355 KWH, and the
monthly figures are written to
Power_Forecast_2014.xlsx.
read_pipe <- function(path) {
d <- read_excel(path, col_names = FALSE, skip = 1,
col_types = c("numeric", "numeric"))
names(d) <- c("serial", "WaterFlow")
d |> mutate(DateTime = as.POSIXct(serial * 86400,
origin = "1899-12-30", tz = "UTC")) |>
select(DateTime, WaterFlow)
}
pipe1 <- read_pipe("data/Waterflow_Pipe1.xlsx")
pipe2 <- read_pipe("data/Waterflow_Pipe2.xlsx")
bind_rows(
pipe1 |> summarise(pipe = "Pipe1", n = n(), from = min(DateTime),
to = max(DateTime)),
pipe2 |> summarise(pipe = "Pipe2", n = n(), from = min(DateTime),
to = max(DateTime))
)
## # A tibble: 2 × 4
## pipe n from to
## <chr> <int> <dttm> <dttm>
## 1 Pipe1 1000 2015-10-23 00:24:06 2015-11-01 23:35:43
## 2 Pipe2 1000 2015-10-23 01:00:00 2015-12-03 16:00:00
Both files hold 1000 readings, but over very different spans. Pipe 1’s thousand readings cover about ten days, so it records several times per hour. Pipe 2’s thousand readings cover about 41 days, which works out at roughly one reading per hour. This is the difference in time stamps the assignment refers to, and it is the reason the two series cannot simply be added together as they stand.
bind_rows(
tibble(pipe = "Pipe1", gap_mins = as.numeric(diff(pipe1$DateTime), units = "mins")),
tibble(pipe = "Pipe2", gap_mins = as.numeric(diff(pipe2$DateTime), units = "mins"))
) |>
group_by(pipe) |>
summarise(median_gap = round(median(gap_mins), 1),
mean_gap = round(mean(gap_mins), 1))
## # A tibble: 2 × 3
## pipe median_gap mean_gap
## <chr> <dbl> <dbl>
## 1 Pipe1 10 14.4
## 2 Pipe2 60 60
The gaps confirm it. Pipe 2 records exactly once an hour, on the hour. Pipe 1 is irregular: its median gap is 10 minutes and its mean gap about 14 minutes, so it averages roughly four readings an hour but with no fixed schedule. Aggregating both to an hourly mean puts them on a common footing, which is what the assignment asks for.
For multiple recordings within an hour I take the mean, as instructed. The timestamp is floored to the hour so that every reading between 09:00:00 and 09:59:59 is assigned to the 09:00 bucket.
hourly <- function(d) {
d |>
mutate(Hour = floor_date(DateTime, "hour")) |>
group_by(Hour) |>
summarise(WaterFlow = mean(WaterFlow), n_readings = n(), .groups = "drop")
}
p1_hr <- hourly(pipe1)
p2_hr <- hourly(pipe2)
head(p1_hr, 10)
## # A tibble: 10 × 3
## Hour WaterFlow n_readings
## <dttm> <dbl> <int>
## 1 2015-10-23 00:00:00 26.1 4
## 2 2015-10-23 01:00:00 18.9 5
## 3 2015-10-23 02:00:00 15.2 2
## 4 2015-10-23 03:00:00 23.1 5
## 5 2015-10-23 04:00:00 15.5 2
## 6 2015-10-23 05:00:00 22.7 9
## 7 2015-10-23 06:00:00 20.6 8
## 8 2015-10-23 07:00:00 18.4 3
## 9 2015-10-23 08:00:00 20.8 3
## 10 2015-10-23 09:00:00 21.2 3
p1_hr |> summarise(min = min(n_readings), median = median(n_readings),
mean = round(mean(n_readings), 1), max = max(n_readings))
## # A tibble: 1 × 4
## min median mean max
## <int> <dbl> <dbl> <int>
## 1 1 4 4.2 10
The n_readings column makes the aggregation visible:
Pipe 1’s hours combine anywhere from a couple of readings to nearly ten
into one mean, about four on average, while Pipe 2’s hours contain one
reading each, so for Pipe 2 the “aggregation” changes nothing.
Why I keep the two pipes separate. The assignment can be read as asking for one combined series, for example total flow through both pipes. I analyse them separately for two reasons. First, they only overlap for about ten days, because Pipe 1 stops on 1 November while Pipe 2 runs to 3 December, so a combined series would either be ten days long or would have to invent Pipe 1’s values for the remaining month. Second, the two pipes turn out to behave differently (one is pure noise, the other has a weak daily cycle), and adding them together would blur exactly the finding that makes this part interesting.
p1_ts <- p1_hr |> select(Hour, WaterFlow) |> as_tsibble(index = Hour) |>
fill_gaps() |>
mutate(WaterFlow = if_else(is.na(WaterFlow),
mean(WaterFlow, na.rm = TRUE), WaterFlow))
p2_ts <- p2_hr |> select(Hour, WaterFlow) |> as_tsibble(index = Hour) |>
fill_gaps() |>
mutate(WaterFlow = if_else(is.na(WaterFlow),
mean(WaterFlow, na.rm = TRUE), WaterFlow))
c(pipe1_hours = nrow(p1_ts), pipe2_hours = nrow(p2_ts))
## pipe1_hours pipe2_hours
## 240 1000
fill_gaps() inserts any hour that had no reading at all
so that the series is regularly spaced, which the modelling functions
require.
bind_rows(p1_ts |> mutate(pipe = "Pipe 1") |> as_tibble(),
p2_ts |> mutate(pipe = "Pipe 2") |> as_tibble()) |>
ggplot(aes(Hour, WaterFlow)) +
geom_line() +
facet_wrap(~pipe, scales = "free", ncol = 1) +
labs(title = "Hourly mean water flow", y = "Water flow", x = NULL)
Pipe 1 looks like noise around a constant level near 20 with no visible trend. Pipe 2 is noisier and sits at a higher level near 40, again without an obvious trend or repeating daily shape.
This is the question the assignment asks directly, so I test it
rather than judge it by eye. unitroot_kpss tests the null
hypothesis that the series is stationary, so a p-value above 0.05 means
I fail to reject stationarity.
bind_rows(
p1_ts |> features(WaterFlow, unitroot_kpss) |> mutate(pipe = "Pipe 1"),
p2_ts |> features(WaterFlow, unitroot_kpss) |> mutate(pipe = "Pipe 2")
) |> select(pipe, kpss_stat, kpss_pvalue)
## # A tibble: 2 × 3
## pipe kpss_stat kpss_pvalue
## <chr> <dbl> <dbl>
## 1 Pipe 1 0.244 0.1
## 2 Pipe 2 0.438 0.0606
bind_rows(
p1_ts |> features(WaterFlow, unitroot_ndiffs) |> mutate(pipe = "Pipe 1"),
p2_ts |> features(WaterFlow, unitroot_ndiffs) |> mutate(pipe = "Pipe 2")
) |> select(pipe, ndiffs)
## # A tibble: 2 × 2
## pipe ndiffs
## <chr> <int>
## 1 Pipe 1 0
## 2 Pipe 2 0
Pipe 1 returns a KPSS p-value of 0.1, which is the ceiling the test reports, so there is no evidence at all against stationarity. Pipe 2 returns about 0.06, which clears the 0.05 threshold but only just, and I would rather flag that than round it up into a cleaner story than the data supports.
The unitroot_ndiffs result settles it. Both series need
zero differences, and that function applies the KPSS test repeatedly
rather than relying on one borderline p-value. Both series are
stationary as they stand, with Pipe 2 the more marginal of the
two.
That answers the first half of the assignment’s question. It also points at something important for the second half: a stationary series has no trend to extrapolate, so whatever forecasting value exists here has to come from autocorrelation instead.
p1_ts |> ACF(WaterFlow, lag_max = 48) |> autoplot() +
labs(title = "Pipe 1 ACF")
p2_ts |> ACF(WaterFlow, lag_max = 48) |> autoplot() +
labs(title = "Pipe 2 ACF")
acf_summary <- function(ts) {
a <- ts |> ACF(WaterFlow, lag_max = 48)
bound <- 1.96 / sqrt(nrow(ts))
tibble(spikes_outside = sum(abs(a$acf) > bound),
bound = round(bound, 3),
lag24 = round(a$acf[24], 3))
}
acf_p1 <- acf_summary(p1_ts)
acf_p2 <- acf_summary(p2_ts)
bind_rows(acf_p1 |> mutate(pipe = "Pipe 1"), acf_p2 |> mutate(pipe = "Pipe 2"))
## # A tibble: 2 × 4
## spikes_outside bound lag24 pipe
## <int> <dbl> <dbl> <chr>
## 1 1 0.127 0.032 Pipe 1
## 2 7 0.062 0.118 Pipe 2
The autocorrelation plots are the crux of it, and they separate the two pipes. With 48 lags each tested at the 5 percent level, about two or three spikes are expected to cross the bound by chance alone even in pure noise.
For Pipe 1, 1 of 48 spikes cross the bound, which is no more than chance would produce, so it is evidence of nothing at all. Lag 24, where a daily cycle would appear, is 0.032, against a bound of ±0.127.
For Pipe 2, 7 of 48 spikes cross the bound, and lag 24 is 0.118 against a bound of ±0.062. That is more than chance would produce, so there is a weak daily signal here. It is small in absolute terms, but it is real.
I confirm this with a formal test rather than leaving it to the eye. The Ljung-Box test asks whether the autocorrelations up to a given lag are jointly different from zero, which is exactly the question the plots raise.
bind_rows(
p1_ts |> features(WaterFlow, ljung_box, lag = 24) |> mutate(pipe = "Pipe 1"),
p2_ts |> features(WaterFlow, ljung_box, lag = 24) |> mutate(pipe = "Pipe 2")
) |> select(pipe, lb_stat, lb_pvalue)
## # A tibble: 2 × 3
## pipe lb_stat lb_pvalue
## <chr> <dbl> <dbl>
## 1 Pipe 1 20.7 0.655
## 2 Pipe 2 48.7 0.00205
The test agrees with the plots and makes the distinction sharp. Pipe 1 returns a p-value near 0.66, so I cannot reject white noise: this series is indistinguishable from random variation about a constant. Pipe 2 returns about 0.002, so I can reject it: there is genuine autocorrelation structure, however weak.
The answer differs by pipe, which is the most interesting finding in this part.
A forecast is only informative when the past tells you something about the future. Both series are stationary, so neither has a trend to extrapolate. That leaves autocorrelation as the only possible source of forecasting power, and the tests above show Pipe 1 has none while Pipe 2 has a little.
On the diagnostics alone, my expectation was that Pipe 1 cannot be forecast in any useful sense, since it is white noise and the optimal forecast is its mean, while Pipe 2 might be forecast a little better than its mean, since a weak daily cycle exists that a model could try to exploit.
These are claims about forecasting, so I test them on a held out test set rather than asserting them from the diagnostics. The test window has to be long enough to test a daily cycle more than once. For Pipe 2, which has about six weeks of data, I hold back a full week (168 hours), the same length as the forecast I am asked to produce. Pipe 1 has only ten days, so a week would leave too little to train on; I hold back two days (48 hours) there.
I compare four candidates. The mean is the natural benchmark for a stationary series. Naive (repeat the last value) is the benchmark for a random walk, and is included to confirm that the series is not one. ARIMA and ETS are chosen automatically. I do not include seasonal naive or STL with a daily season, since neither pipe shows a daily cycle strong enough to justify a model built around one.
fit_pipe <- function(ts, h) {
n <- nrow(ts)
tr <- ts |> slice(1:(n - h))
f <- tr |> model(mean = MEAN(WaterFlow),
naive = NAIVE(WaterFlow),
arima = ARIMA(WaterFlow),
ets = ETS(WaterFlow))
accuracy(f |> forecast(h = h), ts) |> select(.model, RMSE, MAE, MASE) |>
arrange(RMSE)
}
acc_p1 <- fit_pipe(p1_ts, h = 48)
acc_p2 <- fit_pipe(p2_ts, h = 168)
acc_p1
## # A tibble: 4 × 4
## .model RMSE MAE MASE
## <chr> <dbl> <dbl> <dbl>
## 1 ets 3.87 2.88 0.607
## 2 arima 3.87 2.88 0.608
## 3 mean 3.87 2.88 0.608
## 4 naive 6.09 5.07 1.07
acc_p2
## # A tibble: 4 × 4
## .model RMSE MAE MASE
## <chr> <dbl> <dbl> <dbl>
## 1 mean 11.3 7.38 0.757
## 2 ets 11.9 8.68 0.890
## 3 naive 16.9 14.6 1.49
## 4 arima 22.6 20.1 2.06
p2_get <- function(m, col) acc_p2 |> filter(.model == m) |> pull({{ col }})
p2_arima_rmse <- p2_get("arima", RMSE); p2_mean_rmse <- p2_get("mean", RMSE)
p2_arima_mae <- p2_get("arima", MAE); p2_mean_mae <- p2_get("mean", MAE)
p2_rmse_gain <- 100 * (p2_mean_rmse - p2_arima_rmse) / p2_mean_rmse
p1_get <- function(m) acc_p1 |> filter(.model == m) |> pull(RMSE)
p1_best <- acc_p1$.model[1]
On Pipe 1 the mean has a test RMSE of 3.87, ARIMA 3.87 and naive 6.09. The mean and ARIMA are identical because ARIMA has selected the mean model (shown below). Naive is worse than the mean, confirming the series is not a random walk. Nothing beats the mean.
On Pipe 2, ARIMA’s test RMSE is 22.55 against 11.33 for the mean, about 99.1 percent higher, and its MAE is 20.05 against 7.38. This is the opposite of what I expected, and it is the most important result in Part C. The mean is the best model on every measure, ETS is a close second, and ARIMA is not just worse but the worst of the four, behind even naive.
To understand why, I look at which model ARIMA()
actually chose on the training window, since that is the model that
produced the test forecasts.
p2_train <- p2_ts |> slice(1:(nrow(p2_ts) - 168))
p2_train_fit <- p2_train |> model(arima = ARIMA(WaterFlow))
report(p2_train_fit)
## Series: WaterFlow
## Model: ARIMA(1,1,0)(1,0,0)[24] w/ drift
##
## Coefficients:
## ar1 sar1 constant
## -0.5187 0.1598 -0.0171
## s.e. 0.0296 0.0344 0.4789
##
## sigma^2 estimated as 193.2: log likelihood=-3365.16
## AIC=6738.33 AICc=6738.38 BIC=6757.22
model_name <- function(fit) {
sub("Model: ", "", grep("^Model:", capture.output(report(fit)), value = TRUE))
}
p2_train_model <- model_name(p2_train_fit)
p2_full_model <- model_name(p2_ts |> model(arima = ARIMA(WaterFlow)))
p2_train_fit |>
forecast(h = 168) |>
autoplot(p2_ts |> slice((nrow(p2_ts) - 400):nrow(p2_ts)), level = 80) +
labs(title = "Pipe 2: ARIMA chosen on the training window, forecast over the test week",
y = "Water flow", x = NULL)
On the first five weeks of data, the automatic search picks ARIMA(1,1,0)(1,0,0)[24] w/ drift , which is not the model it picks on the full six weeks (ARIMA(0,0,1)(0,0,1)[24] w/ mean, shown below). The lesson is about how fragile automatic order selection is on a noisy series with only weak structure. The search compares models whose AICc values differ by very little, so small changes in the data can flip it to a different specification, and one that extrapolates something that is not really there produces forecasts far worse than the plain mean. A single week of extra data was enough to change the answer.
The decision for Pipe 2 is therefore the mean, not ARIMA. The mean is best on the test week on every measure, it needs no order selection that could go wrong, and its forecast is the same flat line that the full-data ARIMA would converge to after its first day anyway. The weak daily cycle is statistically real, which the Ljung-Box test and the seasonal MA term both confirm, but it is too weak to improve a week-ahead forecast, and trying to exploit it adds risk without adding accuracy.
Neither result is a failure of the modelling. Getting a flat forecast from a series with no usable structure is the correct answer, and recognising that is more useful than forcing a more elaborate model onto data that cannot support one. Had I chosen Pipe 2’s model from the diagnostics alone, I would have picked ARIMA; the test set is what caught the mistake.
p1_ts |> model(ARIMA(WaterFlow)) |> report()
## Series: WaterFlow
## Model: ARIMA(0,0,0) w/ mean
##
## Coefficients:
## constant
## 19.8927
## s.e. 0.2708
##
## sigma^2 estimated as 17.68: log likelihood=-684.72
## AIC=1373.44 AICc=1373.49 BIC=1380.4
p2_ts |> model(ARIMA(WaterFlow)) |> report()
## Series: WaterFlow
## Model: ARIMA(0,0,1)(0,0,1)[24] w/ mean
##
## Coefficients:
## ma1 sma1 constant
## -0.0464 0.1172 39.7950
## s.e. 0.0311 0.0309 0.3764
##
## sigma^2 estimated as 125.8: log likelihood=-3835.06
## AIC=7678.13 AICc=7678.17 BIC=7697.76
The selected ARIMA orders tell the story precisely.
Pipe 1 gives ARIMA(0,0,0) w/ mean. Every order is zero,
which means the search examined the data and concluded there was nothing
to model: no AR terms, no MA terms, no differencing. The model is a
constant plus white noise, and the constant is the series mean of about
19.9. This is the algorithm independently reaching the same conclusion
the ACF plot and the Ljung-Box test did.
Pipe 2 gives ARIMA(0,0,1)(0,0,1)[24] w/ mean. The
seasonal MA term at period 24 is the daily structure the ACF picked up,
and it is the one piece of genuine signal in either pipe. Its
coefficient is small, around 0.12, which is why it would barely move a
forecast away from the mean. I report this model to show what structure
exists, not to forecast with it, for the reasons given above. The
non-seasonal MA term, about -0.05 with a standard error of 0.03, is not
significantly different from zero; the search kept it because it lowered
the AICc slightly, not because it captures anything real.
For both pipes the chosen model is the mean. For Pipe 1 that is also what ARIMA selects, and for Pipe 2 it is what the test week supports. I fit it on the full series and forecast 168 hours, one week.
p1_final <- p1_ts |> model(mean = MEAN(WaterFlow))
p2_final <- p2_ts |> model(mean = MEAN(WaterFlow))
fc1 <- p1_final |> forecast(h = 168)
fc2 <- p2_final |> forecast(h = 168)
fc1 |> autoplot(p1_ts) + labs(title = "Pipe 1, one week ahead", y = "Water flow")
fc2 |> autoplot(p2_ts) + labs(title = "Pipe 2, one week ahead", y = "Water flow")
Both forecasts are flat lines at their series means, about 19.9 for Pipe 1 and 39.8 for Pipe 2, with constant width intervals.
That is exactly what the evidence supports. A flat forecast is not a lazy one here: for a stationary series with little or no autocorrelation, the mean genuinely is the best available prediction, and the prediction interval rather than the point forecast is where the useful information lives. For these pipes the practical takeaway is the expected operating range given by the 80 percent prediction interval, roughly 14.5 to 25.3 for Pipe 1 and 25 to 55 for Pipe 2, rather than any particular hour’s number.
water_out <- bind_rows(
fc1 |> hilo(80) |> as_tibble() |>
transmute(Pipe = "Pipe1", DateTime = format(Hour, "%Y-%m-%d %H:%M:%S"),
Forecast = round(.mean, 3),
lower80 = round(`80%`$lower, 3),
upper80 = round(`80%`$upper, 3)),
fc2 |> hilo(80) |> as_tibble() |>
transmute(Pipe = "Pipe2", DateTime = format(Hour, "%Y-%m-%d %H:%M:%S"),
Forecast = round(.mean, 3),
lower80 = round(`80%`$lower, 3),
upper80 = round(`80%`$upper, 3))
)
write_xlsx(water_out, "Waterflow_Forecast.xlsx")
water_out |> group_by(Pipe) |> summarise(hours = n(),
mean_forecast = round(mean(Forecast), 2))
## # A tibble: 2 × 3
## Pipe hours mean_forecast
## <chr> <int> <dbl>
## 1 Pipe1 168 19.9
## 2 Pipe2 168 39.8
head(water_out, 5)
## # A tibble: 5 × 5
## Pipe DateTime Forecast lower80 upper80
## <chr> <chr> <dbl> <dbl> <dbl>
## 1 Pipe1 2015-11-02 00:00:00 19.9 14.5 25.3
## 2 Pipe1 2015-11-02 01:00:00 19.9 14.5 25.3
## 3 Pipe1 2015-11-02 02:00:00 19.9 14.5 25.3
## 4 Pipe1 2015-11-02 03:00:00 19.9 14.5 25.3
## 5 Pipe1 2015-11-02 04:00:00 19.9 14.5 25.3
Waterflow_Forecast.xlsx holds 336 rows, 168 hourly
forecasts for each pipe.
Part A. The four ATMs turned out to be four different problems rather than one problem repeated. ATM1, ATM2 and ATM4 share a strong weekly cycle with one day far below the rest (Thursday from May 2009 to January 2010, Tuesday from March 2010 onward, which the adaptive models carried into the May forecast), and are well served by seasonal models chosen on a four week test set, with ETS winning on ATM1 and STL on ATM2 and ATM4. On ATM4 the choice of STL over seasonal naive rests on RMSE alone, since seasonal naive was slightly better on MAE, so ATM4’s forecast is the least certain improvement over simply repeating last week. ATM4 also needed a single extreme outlier removed first. ATM3 was not a forecasting problem at all in the usual sense: with three days of history it required a judgement call and a deliberately wide interval rather than a model.
The month’s forecast totals about $2.06 million across the four machines. One caveat travels with it: the Ljung-Box tests on the selected models were not clean, decisively so for ATM4, so the prediction intervals in Part A should be read as a lower bound on the true uncertainty rather than a calibrated range.
Part B. Residential power has a clear upward trend and a strong twice yearly season. One missing month and one implausible July reading were interpolated before modelling. A log transformation was justified here because the seasonal swings grow with the level. ARIMA with drift was best on every accuracy measure on the two year test set, beating seasonal naive’s MAE by about 16 percent, and it left residuals indistinguishable from white noise, so its intervals can be taken at face value. The 2014 forecast totals 93,848,355 KWH, with peaks in January and August.
Part C. The two pipes recorded on different time bases, Pipe 1 irregularly at about four readings an hour and Pipe 2 exactly once an hour, and were aggregated to a common hourly mean. Both proved stationary with zero differences required, so the answer to “is it stationary” is yes for both, though Pipe 2’s KPSS p-value of 0.06 is marginal.
The answer to “can it be forecast” turned out to differ by pipe, which was the most interesting result in the project. Pipe 1 is statistically indistinguishable from white noise: the Ljung-Box p-value is 0.66 and automatic ARIMA selection returns a model with every order set to zero. It cannot meaningfully be forecast, and the mean is the best available answer. Pipe 2 does carry a weak daily cycle, significant at lag 24, which ARIMA picks up as a seasonal MA term. But on a week-long test, the ARIMA chosen automatically had roughly twice the error of the plain mean, and the mean was best on every measure. So the answer for Pipe 2 is that its structure is statistically real but not usable: it can be forecast only in the sense of giving a reliable operating range, not in the sense of predicting particular hours. The mean is the forecast for both pipes. This was the clearest case in the project of the test set overruling what the diagnostics suggested.
The recurring lesson across all three parts is that the modelling itself was the quick bit. What actually determined the quality of each forecast was the work before it: recognising that ATM3’s zeros meant a new machine rather than missing data, that ATM4’s spike and July 2010’s trough were errors rather than events, and that the water flow series were white noise rather than something to model harder.
Every model I fitted, whether I used it, and why.
| Series | Models compared | Chosen | Why chosen | Why the others were not |
|---|---|---|---|---|
| ATM1 | SNAIVE, ETS, ARIMA, STL+ETS | ETS | Lowest test RMSE, MAE and MASE | STL a close second; ARIMA third; SNAIVE clearly worst (RMSE 16.9 vs 12.4) |
| ATM2 | SNAIVE, ETS, ARIMA, STL+ETS | STL+ETS | Lowest test RMSE, MAE and MASE | ETS second; ARIMA third; SNAIVE clearly worst (28.6 vs 18.9) |
| ATM4 | SNAIVE, ETS, ARIMA, STL+ETS | STL+ETS | Lowest test RMSE (245) | SNAIVE close and slightly better on MAE and MASE; ARIMA third; ETS worst on every measure |
| ATM3 | none (3 days of data) | Mean of the 3 days | Only relevant data; machine started 28 April | Any model fitted to 362 zeros would forecast near zero |
| Power | SNAIVE, ETS (log), ARIMA (log), STL+ETS (log) | ARIMA (log) with drift | Best on all four measures; clean residuals | ETS has no trend term; STL no better than SNAIVE on MAPE; SNAIVE ignores the trend |
| Pipe 1 | Mean, Naive, ARIMA, ETS | Mean (ARIMA selects it) | White noise: Ljung-Box p = 0.66, ARIMA(0,0,0); tied best on the two-day test | Nothing to model; Naive much worse (RMSE 6.1 vs 3.9) |
| Pipe 2 | Mean, Naive, ARIMA, ETS | Mean | Best on every measure on the one-week test (RMSE 11.3) | ARIMA worst of the four (RMSE 22.6) despite the weak daily cycle; ETS close second but no better; Naive assumes a random walk the data does not show |
Data decisions I made, and why
Things I deliberately did not do, and why
tibble(
Part = c("A", "B", "C"),
File = c("ATM_Forecast_May2010.xlsx", "Power_Forecast_2014.xlsx",
"Waterflow_Forecast.xlsx"),
Rows = c(nrow(atm_out), nrow(pwr_out), nrow(water_out)),
Contents = c("31 daily forecasts x 4 ATMs, May 2010",
"12 monthly KWH forecasts, 2014",
"168 hourly forecasts x 2 pipes, one week")
)
## # A tibble: 3 × 4
## Part File Rows Contents
## <chr> <chr> <int> <chr>
## 1 A ATM_Forecast_May2010.xlsx 124 31 daily forecasts x 4 ATMs, May 2010
## 2 B Power_Forecast_2014.xlsx 12 12 monthly KWH forecasts, 2014
## 3 C Waterflow_Forecast.xlsx 336 168 hourly forecasts x 2 pipes, one week