This project forecasts cash withdrawals from four ATMs for May 2010 (Part A) and monthly residential power use for 2014 (Part B), and, as the bonus, one week of hourly water flow in two pipes (Part C). The same workflow is used for each part: look at the data, repair only what is clearly wrong and say why, fit a set of benchmark, decomposition, exponential smoothing and ARIMA models, compare them on data held out from the end of the series, check the residuals of the winner, and refit it on all the data to produce the forecast. The comparison uses a single holdout for the ATMs and the water flow, and a holdout plus cross-validation for the power series; the reasons are given in each part.
The file holds daily cash withdrawals in hundreds of dollars for four ATMs. The goal is a daily forecast for every ATM for all 31 days of May 2010, one ATM at a time, because a bank manager has to stock each machine separately.
atm_raw <- read_excel("ATM624Data.xlsx") |>
mutate(DATE = as.Date(DATE, origin = "1899-12-30")) # Excel stores dates as day numbers
# the file ends with 14 rows (1-14 May 2010) that have a date but no ATM and no Cash
atm_raw |> filter(is.na(ATM)) |> summarise(rows = n(), first = min(DATE), last = max(DATE))
## # A tibble: 1 × 3
## rows first last
## <int> <date> <date>
## 1 14 2010-05-01 2010-05-14
atm <- atm_raw |> filter(!is.na(ATM)) |> as_tsibble(key = ATM, index = DATE)
# for each ATM: rows in the data against the number of days from the first to the last date
atm |>
as_tibble() |>
group_by(ATM) |>
summarise(first = min(DATE), last = max(DATE), rows = n(),
days_first_to_last = as.numeric(last - first) + 1) |>
knitr::kable()
| ATM | first | last | rows | days_first_to_last |
|---|---|---|---|---|
| ATM1 | 2009-05-01 | 2010-04-30 | 365 | 365 |
| ATM2 | 2009-05-01 | 2010-04-30 | 365 | 365 |
| ATM3 | 2009-05-01 | 2010-04-30 | 365 | 365 |
| ATM4 | 2009-05-01 | 2010-04-30 | 365 | 365 |
The 14 blank rows at the end are placeholders and not observations, so they are dropped. After that every ATM has all 365 days from 1 May 2009 to 30 April 2010 and the number of rows equals the number of days from the first to the last date, so no date is missing and the daily sequence is intact. Keeping the complete daily index preserves the spacing of the series, so a missing value is filled in and not dropped.
atm |>
as_tibble() |> # summary table of the raw data, not a forecasting step
group_by(ATM) |>
summarise(days = n(), missing = sum(is.na(Cash)), zeros = sum(Cash == 0, na.rm = TRUE),
min = min(Cash, na.rm = TRUE), median = median(Cash, na.rm = TRUE),
mean = mean(Cash, na.rm = TRUE), sd = sd(Cash, na.rm = TRUE),
max = max(Cash, na.rm = TRUE)) |>
knitr::kable(digits = 1)
| ATM | days | missing | zeros | min | median | mean | sd | max |
|---|---|---|---|---|---|---|---|---|
| ATM1 | 365 | 3 | 0 | 1.0 | 91.0 | 83.9 | 36.7 | 180.0 |
| ATM2 | 365 | 2 | 2 | 0.0 | 67.0 | 62.6 | 38.9 | 147.0 |
| ATM3 | 365 | 0 | 362 | 0.0 | 0.0 | 0.7 | 7.9 | 96.0 |
| ATM4 | 365 | 0 | 0 | 1.6 | 403.8 | 474.0 | 650.9 | 10919.8 |
# the only days on which ATM3 has a withdrawal
atm |> filter(ATM == "ATM3", Cash > 0) |> as_tibble() |> knitr::kable()
| DATE | ATM | Cash |
|---|---|---|
| 2010-04-28 | ATM3 | 96 |
| 2010-04-29 | ATM3 | 82 |
| 2010-04-30 | ATM3 | 85 |
# the three largest ATM4 values, to show how far the outlier sits from the rest
atm |> filter(ATM == "ATM4") |> as_tibble() |> slice_max(Cash, n = 3) |> knitr::kable(digits = 1)
| DATE | ATM | Cash |
|---|---|---|
| 2010-02-09 | ATM4 | 10919.8 |
| 2009-09-22 | ATM4 | 1712.1 |
| 2010-01-29 | ATM4 | 1574.8 |
atm |> autoplot(Cash) +
facet_grid(vars(ATM), scales = "free_y") +
labs(title = "Daily cash withdrawals by ATM", y = "Cash (hundreds of dollars)", x = "Date") +
theme(legend.position = "none")
Three things stand out.
The three working ATMs are on very different scales: the median withdrawal is 403.8 for ATM4, 91.0 for ATM1 and 67.0 for ATM2 (the ATM4 standard deviation is inflated by the outlier, so the median is the better guide). That is why the ATMs are modeled one at a time.
This section asks whether the weekly pattern of each working ATM is the same all year. It goes in five steps: spot a possible change, locate it, compare the weekday profiles, look at how deep the low day is, and describe an adjustment I considered and did not use. The recorded dates are not changed.
Step 1: spotting it. The heatmap has one cell per day, with weeks across and weekdays down. Dark blue cells are days with almost no withdrawals compared with that machine’s median.
# heatmap of cash by weekday and week; dark cells are days with almost no withdrawals (display only)
weekday_heatmap <- function(data, title, subtitle) {
data |>
filter(ATM != "ATM3") |>
mutate(Cash = if_else(ATM == "ATM4" & Cash > 5000, NA_real_, Cash), # leave out the ATM4 outlier
weekday = factor(as.character(wday(DATE, label = TRUE, week_start = 1)),
levels = rev(c("Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"))),
week_start = floor_date(DATE, "week", week_start = 1)) |>
group_by(ATM) |>
mutate(relative = pmin(Cash / median(Cash, na.rm = TRUE), 2)) |> # base R pmin() caps the ratio at 2 so the color scale stays readable (display only)
ungroup() |>
ggplot(aes(week_start, weekday, fill = relative)) +
geom_tile(colour = "white", linewidth = 0.2) +
facet_wrap(vars(ATM), ncol = 1) +
scale_fill_gradient2(low = "navy", mid = "whitesmoke", high = "darkorange", midpoint = 1,
na.value = "gray70", name = "Cash / median") +
labs(title = title, subtitle = subtitle, x = "Week starting", y = "Weekday")
}
atm |> as_tibble() |>
weekday_heatmap("Cash by weekday and week, as recorded",
"Dark = almost no withdrawals. Gray = missing value or the ATM4 outlier.")
In every machine the dark row is Thursday until mid-February and Tuesday afterwards. ATM2 has two dark rows, Wednesday and Thursday before the change and Monday and Tuesday after it, so the change involves more than the lowest day.
Step 2: locating it. For each week from January to April 2010 (weeks start on Monday), the table gives the weekday and date with the lowest withdrawal.
# weekday of the lowest withdrawal in each week, leaving out the missing values and the ATM4 outlier
atm |>
filter(ATM != "ATM3", DATE >= as.Date("2010-01-04"), !is.na(Cash), !(ATM == "ATM4" & Cash > 5000)) |>
as_tibble() |>
mutate(week_start = floor_date(DATE, "week", week_start = 1)) |>
group_by(ATM, week_start) |>
slice_min(Cash, n = 1, with_ties = FALSE) |>
ungroup() |>
mutate(lowest = paste(wday(DATE, label = TRUE), format(DATE, "%d %b"))) |>
select(week_start, ATM, lowest) |>
pivot_wider(names_from = ATM, values_from = lowest) |>
knitr::kable()
| week_start | ATM1 | ATM2 | ATM4 |
|---|---|---|---|
| 2010-01-04 | Thu 07 Jan | Thu 07 Jan | Sat 09 Jan |
| 2010-01-11 | Thu 14 Jan | Thu 14 Jan | Thu 14 Jan |
| 2010-01-18 | Thu 21 Jan | Thu 21 Jan | Wed 20 Jan |
| 2010-01-25 | Thu 28 Jan | Thu 28 Jan | Thu 28 Jan |
| 2010-02-01 | Thu 04 Feb | Thu 04 Feb | Sun 07 Feb |
| 2010-02-08 | Sun 14 Feb | Thu 11 Feb | Wed 10 Feb |
| 2010-02-15 | Mon 15 Feb | Mon 15 Feb | Sat 20 Feb |
| 2010-02-22 | Tue 23 Feb | Tue 23 Feb | Tue 23 Feb |
| 2010-03-01 | Tue 02 Mar | Tue 02 Mar | Sun 07 Mar |
| 2010-03-08 | Tue 09 Mar | Tue 09 Mar | Tue 09 Mar |
| 2010-03-15 | Tue 16 Mar | Tue 16 Mar | Tue 16 Mar |
| 2010-03-22 | Tue 23 Mar | Tue 23 Mar | Tue 23 Mar |
| 2010-03-29 | Tue 30 Mar | Tue 30 Mar | Tue 30 Mar |
| 2010-04-05 | Tue 06 Apr | Tue 06 Apr | Tue 06 Apr |
| 2010-04-12 | Tue 13 Apr | Tue 13 Apr | Tue 13 Apr |
| 2010-04-19 | Tue 20 Apr | Tue 20 Apr | Sat 24 Apr |
| 2010-04-26 | Tue 27 Apr | Tue 27 Apr | Tue 27 Apr |
Down the ATM1 and ATM2 columns the lowest day is a Thursday in every week through the week of 1 February (ATM2 is still on Thursday 11 February in the week of 8 February). The weeks of 8 and 15 February are mixed (Sunday, Monday, Thursday), and from the week of 22 February the lowest day is a Tuesday in every week through the end of April. ATM4 makes the same move from Thursday to Tuesday, but its lowest day is less regular because the machine is noisier: in the five weeks before the move it is a Thursday in two (the others are Saturday, Wednesday and Sunday), and in the ten weeks after it a Tuesday in eight, with a Sunday (week of 1 March) and a Saturday (week of 19 April) in the other two.
Step 3: comparing the weekday profiles. The next graph gives the average cash for each weekday before the change (1 May 2009 to 8 February 2010) and after it (22 February to 30 March 2010, the last day used for fitting the models). The weeks in between are left out.
# average cash by weekday and period, leaving out the missing values and the ATM4 outlier (display only)
weekday_profile <- function(data) {
data |>
filter(ATM != "ATM3", !is.na(Cash), !(ATM == "ATM4" & Cash > 5000)) |>
mutate(period = case_when(DATE < as.Date("2010-02-09") ~ "before",
DATE >= as.Date("2010-02-22") & DATE <= as.Date("2010-03-30") ~ "after"),
weekday = wday(DATE, label = TRUE, week_start = 1)) |>
filter(!is.na(period)) |>
group_by(ATM, period, weekday) |>
summarise(mean_cash = mean(Cash), .groups = "drop")
}
atm |> as_tibble() |> weekday_profile() |>
mutate(period = if_else(period == "before", "before (1 May 2009 to 8 Feb 2010)",
"after (22 Feb to 30 Mar 2010)")) |>
ggplot(aes(weekday, mean_cash, colour = period, group = period)) +
geom_line(linewidth = 1) + geom_point(size = 2) +
facet_wrap(vars(ATM), scales = "free_y", ncol = 1) +
scale_colour_manual(values = c("firebrick", "steelblue")) +
labs(title = "Average cash by weekday, before and after the change",
x = "Weekday", y = "Average cash (hundreds of dollars)", colour = NULL) +
theme(legend.position = "bottom")
In every working machine the lowest weekday average is Thursday before the change and Tuesday after it. ATM2 also has a second low day, Wednesday before and Monday after. The other weekdays change too, and the two weekday profiles resemble a two-day shift.
Step 4: how deep is the low day? The table gives the withdrawals on the low day of each working machine: the Thursdays before 9 February and the Tuesdays from 22 February to 30 March.
# withdrawals on the low day before and after the change (display only)
atm |>
filter(ATM != "ATM3", !is.na(Cash), !(ATM == "ATM4" & Cash > 5000)) |>
as_tibble() |>
mutate(weekday = wday(DATE, label = TRUE),
low_day = case_when(DATE < as.Date("2010-02-09") & weekday == "Thu" ~ "Thursdays before 9 Feb",
DATE >= as.Date("2010-02-22") & DATE <= as.Date("2010-03-30") & weekday == "Tue" ~
"Tuesdays 22 Feb to 30 Mar")) |>
filter(!is.na(low_day)) |>
group_by(ATM, low_day) |>
summarise(days = n(), mean = mean(Cash), median = median(Cash), sd = sd(Cash), .groups = "drop") |>
knitr::kable(digits = 1)
| ATM | low_day | days | mean | median | sd |
|---|---|---|---|---|---|
| ATM1 | Thursdays before 9 Feb | 40 | 15.3 | 14.0 | 7.0 |
| ATM1 | Tuesdays 22 Feb to 30 Mar | 6 | 5.8 | 4.0 | 6.6 |
| ATM2 | Thursdays before 9 Feb | 39 | 8.1 | 7.0 | 6.3 |
| ATM2 | Tuesdays 22 Feb to 30 Mar | 6 | 1.5 | 1.5 | 1.0 |
| ATM4 | Thursdays before 9 Feb | 40 | 62.3 | 48.6 | 54.6 |
| ATM4 | Tuesdays 22 Feb to 30 Mar | 6 | 22.2 | 24.9 | 10.2 |
The low day is deeper after the change as well as on a different weekday. The median withdrawal on the low day falls from 14.0 to 4.0 for ATM1, from 7.0 to 1.5 for ATM2 and from 48.6 to 24.9 for ATM4. For ATM2 and ATM4 the low-day withdrawals are also less spread out after the change (standard deviation 1.0 against 6.3 and 10.2 against 54.6); for ATM1 the spread is about the same (6.6 against 7.0).
Step 5: an adjustment I considered and did not use. One option is to move the earlier observations two days earlier, so that their weekly pattern matches the later one. The graph shows what that would do: it overlays the average weekday profile before the change, moved two days earlier, on the profile after the change.
# what moving the earlier days two days earlier would do to the weekday profile (display only)
weekday_names <- c("Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun")
profile <- atm |> as_tibble() |> weekday_profile()
bind_rows(
profile |> filter(period == "after") |>
mutate(weekday = factor(as.character(weekday), levels = weekday_names),
period = "after (22 Feb to 30 Mar 2010)"),
profile |> filter(period == "before") |>
mutate(weekday = factor(weekday_names[(as.integer(weekday) - 1 - 2) %% 7 + 1], levels = weekday_names),
period = "before, moved two days earlier")) |>
ggplot(aes(weekday, mean_cash, colour = period, group = period)) +
geom_line(linewidth = 1) + geom_point(size = 2) +
facet_wrap(vars(ATM), scales = "free_y", ncol = 1) +
scale_colour_manual(values = c("firebrick", "steelblue")) +
labs(title = "The earlier weekday profile moved two days earlier, against the later profile",
x = "Weekday", y = "Average cash (hundreds of dollars)", colour = NULL) +
theme(legend.position = "bottom")
The move brings the low days into the same weekday positions, but it would leave two dates empty at the transition, and those would then have to be filled. The table in step 4 also shows that withdrawals on the low day became lower after the change, so the difference involves both the timing and the level of the withdrawals. The data cannot show whether the change came from wrong dates or from a change in how the machines are operated. Moving the dates would impose an unverified explanation and create dates that are not in the original records, so I kept the recorded dates and treat the February change as a limitation: models fitted to the whole year are compared with models fitted only to the days after the change (section A7), and the May forecast assumes that the later pattern continues.
The usual rule for a time series is not to delete rows: a bad or missing value is replaced by the mean or median of comparable days, for example the same weekday, and the replacement is documented. Kuhn and Johnson (Applied Predictive Modeling, sections 3.3 and 3.4) add two rules that apply here: an unusual value should first be checked for validity and recording errors, and should not be removed or changed hastily, and an imputation should be built from the training data only. Here every value that needs replacing is replaced by the median of the same weekday for that machine over the days before 9 February 2010, the days before the weekly pattern changed; this uses no day of the 31-day holdout. A median is used because it is not pulled by extreme values. The table lists every replaced value.
change_start <- as.Date("2010-02-09") # first day of the weeks in which the weekly pattern changes
atm_flagged <- atm |>
as_tibble() |>
mutate(replace = is.na(Cash) | (ATM == "ATM4" & Cash > 5000), # missing, or the 10,920 value
weekday = wday(DATE, label = TRUE))
# median Cash for each ATM and weekday, from the days before the change that are not replaced
weekday_median <- atm_flagged |>
filter(!replace, DATE < change_start) |>
group_by(ATM, weekday) |>
summarise(typical = median(Cash), .groups = "drop")
atm_clean <- atm_flagged |>
left_join(weekday_median, by = c("ATM", "weekday")) |>
mutate(Cash_original = Cash,
Cash = if_else(replace, typical, Cash)) |> # swap in the weekday median only where flagged
as_tsibble(key = ATM, index = DATE)
atm_clean |>
filter(replace) |>
as_tibble() |>
select(ATM, DATE, weekday, original = Cash_original, replacement = Cash) |>
knitr::kable(digits = 1)
| ATM | DATE | weekday | original | replacement |
|---|---|---|---|---|
| ATM1 | 2009-06-13 | Sat | NA | 96.5 |
| ATM1 | 2009-06-16 | Tue | NA | 108.0 |
| ATM1 | 2009-06-22 | Mon | NA | 90.0 |
| ATM2 | 2009-06-18 | Thu | NA | 7.0 |
| ATM2 | 2009-06-24 | Wed | NA | 26.0 |
| ATM4 | 2010-02-09 | Tue | 10919.8 | 483.4 |
The table shows six values: the five values missing in the file (June 2009) and the ATM4 outlier on 9 February 2010. The ATM4 mean in the A2 table is above its median, partly because of that outlier, which is one reason to prefer the median. The zeros of ATM2 are not replaced; the next table shows where they fall and what is typical for that weekday in the same period.
# the ATM2 zeros, with the typical value for their weekday before or after the change
atm_clean |>
as_tibble() |>
filter(ATM == "ATM2") |>
mutate(period = case_when(DATE < change_start ~ "before", DATE >= as.Date("2010-02-22") ~ "after")) |>
group_by(period, weekday) |>
mutate(typical_same_period = median(Cash[!replace])) |>
ungroup() |>
filter(Cash_original == 0) |>
select(DATE, weekday, period, original = Cash_original, typical_same_period) |>
knitr::kable(digits = 1)
| DATE | weekday | period | original | typical_same_period |
|---|---|---|---|---|
| 2009-10-25 | Sun | before | 0 | 62.0 |
| 2010-03-30 | Tue | after | 0 | 1.5 |
The zero on 30 March 2010 is a Tuesday, the low day after the change, where the typical ATM2 value is 1.5, so it is ordinary. The zero on 25 October 2009 is a Sunday, where the typical value before the change is 62.0, so it is unusual. A zero withdrawal can happen and nothing in the data shows it is an error, so both zeros stay in.
train_end <- as.Date("2010-03-30") # last day used for fitting; the last 31 days are kept for testing
atm_train <- atm_clean |> filter(ATM != "ATM3", DATE <= train_end) # the test set is 31 Mar to 30 Apr (31 days)
# Guerrero's lambda for each ATM, from training data only
atm_train |> features(Cash, guerrero) |> knitr::kable(digits = 3)
| ATM | lambda_guerrero |
|---|---|
| ATM1 | 0.231 |
| ATM2 | 0.524 |
| ATM4 | 0.308 |
The Guerrero lambdas are 0.231 for ATM1, 0.524 for ATM2 and 0.308 for ATM4; each machine uses its own value. As Kuhn and Johnson do for the Box-Cox transformation (section 3.2), the lambdas are estimated from the training days only. Box-Cox versions of the ETS and ARIMA models are included in the comparison to see whether they help. The last 31 days (31 March to 30 April 2010) are held out because May is also 31 days. The training period includes the days from 22 February to 30 March, which follow the later weekly pattern, so the holdout tests whether a model has picked that pattern up.
The first table gives the strength of the trend and of the weekly pattern for each machine (between 0 and 1), and the graph gives the autocorrelation of the cleaned series.
# strength of the trend and of the weekly pattern (between 0 and 1)
atm_train |> features(Cash, feat_stl) |>
select(ATM, trend_strength, seasonal_strength_week) |>
knitr::kable(digits = 2)
| ATM | trend_strength | seasonal_strength_week |
|---|---|---|
| ATM1 | 0.34 | 0.77 |
| ATM2 | 0.30 | 0.78 |
| ATM4 | 0.27 | 0.35 |
# autocorrelation of the cleaned series: spikes at lags 7, 14, 21 mean a weekly pattern
atm_train |> ACF(Cash, lag_max = 28) |> autoplot() +
labs(title = "ACF of daily cash by ATM")
The weekly pattern is strong for ATM1 and ATM2 and weaker for ATM4, whose withdrawals are noisier. The ACF of ATM1 and ATM2 has large spikes at lags 7, 14, 21 and 28, which confirms a weekly pattern, so the seasonal period is 7; ATM4 has a much smaller spike at lag 7, in line with its lower seasonal strength.
The next table gives the KPSS statistic and the number of seasonal and ordinary differences suggested for each machine.
atm_train |> features(Cash, list(unitroot_kpss, unitroot_nsdiffs, unitroot_ndiffs)) |>
knitr::kable(digits = 3)
| ATM | kpss_stat | kpss_pvalue | nsdiffs | ndiffs |
|---|---|---|---|---|
| ATM1 | 0.376 | 0.087 | 1 | 0 |
| ATM2 | 2.242 | 0.010 | 1 | 1 |
| ATM4 | 0.087 | 0.100 | 0 | 0 |
ATM1 and ATM2 need one seasonal difference, and ATM4 none.
The graph shows ATM1 after one seasonal difference, with its ACF and PACF.
atm_train |> filter(ATM == "ATM1") |>
gg_tsdisplay(difference(Cash, 7), plot_type = "partial") +
labs(title = "ATM1 after one seasonal difference (lag 7)")
## Warning: Removed 7 rows containing missing values or values outside the scale range
## (`geom_line()`).
## Warning: Removed 7 rows containing missing values or values outside the scale range
## (`geom_point()`).
After the seasonal difference the ACF has a spike at lag 1 and a
spike at lag 7, and the PACF decays at the seasonal lags (the warnings
about removed rows are the first 7 values lost to the seasonal
difference). That suggests ARIMA(0,0,1)(0,1,1)[7] as a manual candidate,
and ARIMA(1,0,0)(0,1,1)[7] as a close alternative. These are fitted next
to the automatic ARIMA() and the full search
(stepwise = FALSE, approx = FALSE), so that several ARIMA
models are compared and not one automatic fit.
| What the exploration shows | Where it is shown | What it led to |
|---|---|---|
| ATM3 is zero except for the last three days | A2 summary and ATM3 tables | ATM3 gets a simple forecast and no model comparison |
| One suspected erroneous ATM4 value; a few missing values in ATM1 and ATM2 | A2 tables and time plot, A4 | Replace them with same-weekday medians, keep the row order |
| A weekly pattern in every working ATM (strong for ATM1 and ATM2, weaker for ATM4) | A3 heatmap, A6 strength table and ACF | Seasonal period 7 in every seasonal model |
| The weekly pattern moves by two days between 9 and 22 February 2010, and the low day becomes deeper | A3 steps 1 to 4 | Keep the recorded dates, take the replacement medians from before the change, compare models fitted to the whole year with models fitted to the later days only (A7), and assume the later pattern continues in May |
Each ATM gets the same set of models fitted to the days of the whole year up to 30 March: the four benchmarks (mean, naive, seasonal naive, drift), automatic ETS, ETS on Box-Cox data, STL with ETS on the seasonally adjusted data, automatic ARIMA, the full ARIMA search, two manual seasonal ARIMA models and a Box-Cox ARIMA. Because the weekly pattern changed in February (section A3), three more models are fitted only to the days from 22 February: seasonal naive, ETS and automatic ARIMA. They show whether the older days hurt, at the cost of a very short training series; the first table gives the number of training days of each history.
recent_start <- as.Date("2010-02-22") # first week with the later weekly pattern
# model definitions for the whole-year history; lambda is the Box-Cox parameter of the series
atm_specs <- function(lambda) {
list(
mean = MEAN(Cash),
naive = NAIVE(Cash),
snaive = SNAIVE(Cash),
drift = RW(Cash ~ drift()),
ets = ETS(Cash),
ets_boxcox = ETS(box_cox(Cash, lambda)), # Box-Cox inside ETS() is only mentioned in exercise 8.13 of the book
stl_ets = decomposition_model(STL(Cash ~ season(window = 7), robust = TRUE),
ETS(season_adjust ~ season("N"))),
arima_auto = ARIMA(Cash),
arima_search = ARIMA(Cash, stepwise = FALSE, approx = FALSE),
arima_001_011 = ARIMA(Cash ~ 0 + pdq(0, 0, 1) + PDQ(0, 1, 1)),
arima_100_011 = ARIMA(Cash ~ 0 + pdq(1, 0, 0) + PDQ(0, 1, 1)),
arima_boxcox = ARIMA(box_cox(Cash, lambda))
)
}
# fit all the whole-year models, or only those named in `which`
fit_atm <- function(series, which = NULL) {
lambda <- series |> features(Cash, guerrero) |> pull(lambda_guerrero)
specs <- atm_specs(lambda)
if (!is.null(which)) specs <- specs[which]
do.call(model, c(list(series), specs))
}
# the three models for the days from 22 February only
fit_atm_recent <- function(series, which = NULL) {
specs <- list(snaive_recent = SNAIVE(Cash), ets_recent = ETS(Cash), arima_auto_recent = ARIMA(Cash))
if (!is.null(which)) specs <- specs[which]
do.call(model, c(list(series), specs))
}
working_atms <- c("ATM1", "ATM2", "ATM4")
atm_fits <- map(set_names(working_atms), \(k) fit_atm(atm_train |> filter(ATM == k)))
atm_fits_recent <- map(set_names(working_atms),
\(k) fit_atm_recent(atm_train |> filter(ATM == k, DATE >= recent_start)))
# training days used by each history
atm_train |>
as_tibble() |>
group_by(ATM) |>
summarise(whole_year = n(), from_22_Feb = sum(DATE >= recent_start)) |>
knitr::kable()
| ATM | whole_year | from_22_Feb |
|---|---|---|
| ATM1 | 334 | 37 |
| ATM2 | 334 | 37 |
| ATM4 | 334 | 37 |
A helper computes the Ljung-Box test with the book’s rules: lag 2m = 14 for a weekly pattern and degrees of freedom p + q + P + Q for ARIMA models. Candidate models are then compared by RMSE and MAE on the holdout, together with the residual test.
# Ljung-Box p-value on innovation residuals, the same test as ljung_box(lag, dof) in the book;
# dof differs by model (number of ARMA terms for ARIMA models, 0 otherwise), so it is applied here with pchisq()
ljung_table <- function(fit, lag) {
stats <- fit |> augment() |> features(.innov, ljung_box, lag = lag) |>
select(.model, lb_stat)
arima_terms <- fit |> select(starts_with("arima")) |> tidy() |>
filter(term != "constant") |> count(.model, name = "dof")
stats |> left_join(arima_terms, by = ".model") |>
mutate(dof = replace_na(dof, 0),
lb_pvalue = pchisq(lb_stat, df = pmax(lag - dof, 1), lower.tail = FALSE)) |>
select(.model, lb_pvalue)
}
# holdout accuracy and residual test of one set of fits
score_fits <- function(fits, test, horizon, lag, history) {
accuracy(forecast(fits, h = horizon), test) |>
as_tibble() |> select(.model, RMSE, MAE) |>
left_join(ljung_table(fits, lag), by = ".model") |>
mutate(history = history)
}
atm_accuracy <- map(working_atms, \(k) {
test <- atm_clean |> filter(ATM == k)
bind_rows(score_fits(atm_fits[[k]], test, 31, 14, "whole year"),
score_fits(atm_fits_recent[[k]], test, 31, 14, "from 22 Feb")) |>
mutate(ATM = k)
}) |> list_rbind()
atm_accuracy |>
arrange(ATM, RMSE) |>
select(ATM, model = .model, history, RMSE, MAE, ljung_box_p = lb_pvalue) |>
knitr::kable(digits = 3)
| ATM | model | history | RMSE | MAE | ljung_box_p |
|---|---|---|---|---|---|
| ATM1 | ets | whole year | 12.748 | 9.878 | 0.012 |
| ATM1 | arima_001_011 | whole year | 12.848 | 10.011 | 0.088 |
| ATM1 | arima_100_011 | whole year | 12.861 | 10.013 | 0.051 |
| ATM1 | arima_auto_recent | from 22 Feb | 13.195 | 10.574 | 0.126 |
| ATM1 | stl_ets | whole year | 13.788 | 10.388 | 0.014 |
| ATM1 | arima_auto | whole year | 13.846 | 10.782 | 0.116 |
| ATM1 | arima_boxcox | whole year | 14.340 | 11.675 | 0.544 |
| ATM1 | ets_boxcox | whole year | 14.921 | 11.960 | 0.180 |
| ATM1 | arima_search | whole year | 15.584 | 11.815 | 0.158 |
| ATM1 | snaive | whole year | 15.862 | 13.161 | 0.000 |
| ATM1 | snaive_recent | from 22 Feb | 15.862 | 13.161 | 0.031 |
| ATM1 | ets_recent | from 22 Feb | 16.021 | 12.122 | 0.217 |
| ATM1 | mean | whole year | 32.237 | 20.174 | 0.000 |
| ATM1 | naive | whole year | 67.198 | 63.290 | 0.000 |
| ATM1 | drift | whole year | 70.341 | 65.946 | 0.000 |
| ATM2 | ets_boxcox | whole year | 19.292 | 14.121 | 0.008 |
| ATM2 | ets | whole year | 19.414 | 14.695 | 0.001 |
| ATM2 | arima_100_011 | whole year | 19.469 | 14.696 | 0.001 |
| ATM2 | arima_001_011 | whole year | 19.471 | 14.698 | 0.001 |
| ATM2 | arima_auto_recent | from 22 Feb | 20.224 | 14.393 | 0.287 |
| ATM2 | ets_recent | from 22 Feb | 20.485 | 13.708 | 0.664 |
| ATM2 | stl_ets | whole year | 20.485 | 13.565 | 0.819 |
| ATM2 | arima_boxcox | whole year | 20.747 | 15.711 | 0.291 |
| ATM2 | arima_auto | whole year | 20.848 | 17.006 | 0.485 |
| ATM2 | arima_search | whole year | 23.209 | 18.668 | 0.421 |
| ATM2 | snaive | whole year | 26.821 | 19.839 | 0.000 |
| ATM2 | snaive_recent | from 22 Feb | 26.821 | 19.839 | 0.057 |
| ATM2 | mean | whole year | 37.194 | 32.386 | 0.000 |
| ATM2 | naive | whole year | 72.909 | 62.710 | 0.000 |
| ATM2 | drift | whole year | 77.406 | 67.851 | 0.000 |
| ATM4 | ets_boxcox | whole year | 245.985 | 203.635 | 0.530 |
| ATM4 | stl_ets | whole year | 251.690 | 207.075 | 0.051 |
| ATM4 | arima_boxcox | whole year | 252.852 | 216.871 | 0.212 |
| ATM4 | ets_recent | from 22 Feb | 259.887 | 207.516 | 0.577 |
| ATM4 | arima_100_011 | whole year | 268.278 | 226.576 | 0.408 |
| ATM4 | arima_001_011 | whole year | 268.508 | 226.698 | 0.401 |
| ATM4 | arima_search | whole year | 268.727 | 226.967 | 0.326 |
| ATM4 | arima_auto | whole year | 280.540 | 237.567 | 0.413 |
| ATM4 | arima_auto_recent | from 22 Feb | 281.337 | 235.661 | 0.687 |
| ATM4 | mean | whole year | 281.377 | 235.697 | 0.024 |
| ATM4 | ets | whole year | 304.354 | 255.172 | 0.414 |
| ATM4 | snaive | whole year | 329.584 | 260.177 | 0.000 |
| ATM4 | snaive_recent | from 22 Feb | 329.584 | 260.177 | 0.215 |
| ATM4 | naive | whole year | 466.056 | 378.190 | 0.000 |
| ATM4 | drift | whole year | 493.200 | 411.604 | 0.000 |
RMSE and MAE are in hundreds of dollars. The naive and drift forecasts, which have no weekly pattern, are far behind for every ATM, and the plain mean is far behind for ATM1 and ATM2 but not for ATM4. The seasonal naive forecast is the right simple benchmark and is beaten by the seasonal ETS and ARIMA models; the seasonal naive forecast fitted from 22 February is identical to the whole-year one, because it only repeats the last week. RMSE gives more weight to large misses than MAE does; for the top models of each ATM the two measures usually give a similar order, with some differences (for ATM2 the STL model has the lowest MAE but not the lowest RMSE). The three models fitted only to the days from 22 February are not clearly better than the whole-year models. For ATM1 and ATM4 the best of them (RMSE 13.195 and 259.887) is behind the best whole-year model (12.748 and 245.985). For ATM2 the ARIMA fitted from 22 February (20.224) comes first among the models that pass the residual test, because the whole-year ETS and ARIMA models with a lower RMSE leave autocorrelation in the residuals (Ljung-Box p-values of 0.008 or less).
Selection rule. For each ATM I take the model with the lowest holdout RMSE among those whose residuals pass the Ljung-Box test at the 5% level, whichever history it was fitted to. Accuracy decides, and the residual test checks whether the residuals still show autocorrelation; passing it does not prove that there is none.
atm_selected <- atm_accuracy |>
filter(lb_pvalue >= 0.05) |> # keep models that pass the residual test
group_by(ATM) |>
slice_min(RMSE, n = 1) |> # lowest holdout RMSE for each ATM
ungroup()
atm_selected |> select(ATM, model = .model, history, RMSE, MAE, ljung_box_p = lb_pvalue) |>
knitr::kable(digits = 3)
| ATM | model | history | RMSE | MAE | ljung_box_p |
|---|---|---|---|---|---|
| ATM1 | arima_001_011 | whole year | 12.848 | 10.011 | 0.088 |
| ATM2 | arima_auto_recent | from 22 Feb | 20.224 | 14.393 | 0.287 |
| ATM4 | ets_boxcox | whole year | 245.985 | 203.635 | 0.530 |
Kuhn and Johnson (section 4.6) advise favoring a simpler model whose performance is within a small tolerance of the best. The table lists, for each ATM, the models that pass the residual test and are within 1% of the lowest RMSE.
# passing models within 1% of the best RMSE, for each ATM
atm_accuracy |>
filter(lb_pvalue >= 0.05) |>
group_by(ATM) |>
mutate(percent_above_best = 100 * (RMSE / min(RMSE) - 1)) |>
filter(percent_above_best <= 1) |>
ungroup() |>
arrange(ATM, RMSE) |>
select(ATM, model = .model, history, RMSE, percent_above_best) |>
knitr::kable(digits = 3)
| ATM | model | history | RMSE | percent_above_best |
|---|---|---|---|---|
| ATM1 | arima_001_011 | whole year | 12.848 | 0.000 |
| ATM1 | arima_100_011 | whole year | 12.861 | 0.102 |
| ATM2 | arima_auto_recent | from 22 Feb | 20.224 | 0.000 |
| ATM4 | ets_boxcox | whole year | 245.985 | 0.000 |
For ATM2 and ATM4 the best model is alone in that range. For ATM1 the range holds two seasonal ARIMA models that differ only in a non-seasonal MA(1) against AR(1) term and by 0.1% in RMSE, and neither is clearly simpler, so I keep the lowest-RMSE choice. The rule therefore changes no choice for the ATMs.
# residual check for each selected model
for (k in atm_selected$ATM) {
sel <- atm_selected |> filter(ATM == k)
fits <- if (sel$history == "whole year") atm_fits[[k]] else atm_fits_recent[[k]]
print(fits |> select(all_of(sel$.model)) |> gg_tsresiduals() +
labs(title = paste(k, "-", sel$.model)))
}
The rule picks a different kind of model for each ATM. For ATM1 the automatic ETS has the lowest RMSE in the accuracy table (12.748), but its residuals fail the Ljung-Box test (p = 0.012), so the rule picks the ARIMA(0,0,1)(0,1,1)[7] model (12.848, p = 0.088). For ATM2 it picks the automatic ARIMA fitted from 22 February (20.224, p = 0.287). For ATM4 it picks the ETS on Box-Cox data (245.985, p = 0.530), which is not far below the plain mean (281.377). The residual plots support the choices. The autocorrelations of ATM1 stay inside the dashed lines except for one lag that is just outside, and those of ATM4 stay inside. The ATM1 residuals are bunched around zero with a few large values, mostly negative (days with much less cash than the model expects), so their tails are heavy and the prediction intervals are approximate; the ATM4 residuals have a few large positive values. The ATM2 model rests on only 37 training days, so the first seven residuals are zero and the autocorrelation bounds are wide; its choice is less certain than the others.
In section A3 the low day is much lower after the change than before it, so a model fitted to the whole year may not have caught up with that. The next tables compare the forecast with the actual values for the four Tuesdays of the holdout (6, 13, 20 and 27 April), using the selected model of each ATM.
# forecast and actual for every holdout day, using the selected model of each ATM
holdout_check <- map(working_atms, \(k) {
sel <- atm_selected |> filter(ATM == k)
fits <- if (sel$history == "whole year") atm_fits[[k]] else atm_fits_recent[[k]]
fits |> select(all_of(sel$.model)) |> forecast(h = 31) |>
as_tibble() |>
select(DATE, forecast = .mean) |>
left_join(atm_clean |> filter(ATM == k) |> as_tibble() |> select(DATE, actual = Cash), by = "DATE") |>
mutate(ATM = k, model = sel$.model, weekday = wday(DATE, label = TRUE))
}) |> list_rbind()
holdout_check |>
filter(weekday == "Tue") |>
transmute(ATM, model, DATE, actual, forecast, forecast_minus_actual = forecast - actual) |>
knitr::kable(digits = 1)
| ATM | model | DATE | actual | forecast | forecast_minus_actual |
|---|---|---|---|---|---|
| ATM1 | arima_001_011 | 2010-04-06 | 4.0 | 16.4 | 12.4 |
| ATM1 | arima_001_011 | 2010-04-13 | 3.0 | 16.4 | 13.4 |
| ATM1 | arima_001_011 | 2010-04-20 | 3.0 | 16.4 | 13.4 |
| ATM1 | arima_001_011 | 2010-04-27 | 4.0 | 16.4 | 12.4 |
| ATM2 | arima_auto_recent | 2010-04-06 | 2.0 | 0.6 | -1.4 |
| ATM2 | arima_auto_recent | 2010-04-13 | 1.0 | 0.2 | -0.8 |
| ATM2 | arima_auto_recent | 2010-04-20 | 1.0 | 0.5 | -0.5 |
| ATM2 | arima_auto_recent | 2010-04-27 | 2.0 | 0.3 | -1.7 |
| ATM4 | ets_boxcox | 2010-04-06 | 2.5 | 142.1 | 139.6 |
| ATM4 | ets_boxcox | 2010-04-13 | 27.3 | 144.9 | 117.6 |
| ATM4 | ets_boxcox | 2010-04-20 | 29.9 | 147.8 | 117.9 |
| ATM4 | ets_boxcox | 2010-04-27 | 13.7 | 150.6 | 136.9 |
# how much of each machine's squared holdout error comes from the four Tuesdays
holdout_check |>
group_by(ATM) |>
summarise(mean_error_on_Tuesdays = mean((forecast - actual)[weekday == "Tue"]),
percent_of_squared_error_from_Tuesdays =
100 * sum(((forecast - actual)^2)[weekday == "Tue"]) / sum((forecast - actual)^2)) |>
knitr::kable(digits = 2)
| ATM | mean_error_on_Tuesdays | percent_of_squared_error_from_Tuesdays |
|---|---|---|
| ATM1 | 12.88 | 12.99 |
| ATM2 | -1.09 | 0.04 |
| ATM4 | 128.01 | 3.52 |
For ATM2 the forecast is close to the actual Tuesdays (0.2 to 0.6 against 1 to 2). For ATM1 and ATM4 the selected models forecast Tuesdays that are far too high: 16.4 against actual values of 3 to 4 for ATM1, and 142.1 to 150.6 against 2.5 to 29.9 for ATM4. On average that is 12.9 and 128.0 hundred dollars too high. A likely reason is that these two models are fitted to the whole year and at the end of March had seen only six of the later Tuesdays (the table in section A3), so they had not yet picked up the deeper low day. I keep the selection rule unchanged and record this as a limitation. For the selected models, Tuesdays are 13.0% of ATM1’s squared holdout error, 0.04% of ATM2’s and 3.5% of ATM4’s, so the ranking of the models is also driven by the other days.
ATM3 has only three active days (96, 82 and 85 hundreds of dollars on 28 to 30 April 2010). With three points there is no way to estimate a weekly pattern or validate a model, so the candidates are the two benchmarks that make sense: the mean of the active days and the naive forecast (the last value).
atm3_active <- atm_clean |> filter(ATM == "ATM3", Cash > 0)
atm3_active |> select(DATE, Cash) # the only active days
## # A tsibble: 3 x 2 [1D]
## DATE Cash
## <date> <dbl>
## 1 2010-04-28 96
## 2 2010-04-29 82
## 3 2010-04-30 85
tibble(method = c("mean of the three active days", "naive (last value)"),
cash_hundreds = c(mean(atm3_active$Cash), last(atm3_active$Cash))) |>
mutate(dollars = cash_hundreds * 100) |>
knitr::kable(digits = 1)
| method | cash_hundreds | dollars |
|---|---|---|
| mean of the three active days | 87.7 | 8766.7 |
| naive (last value) | 85.0 | 8500.0 |
I use the mean of the three active days as the ATM3 forecast, about 8,767 dollars a day. The mean of the whole series would be close to zero and would describe a machine that was off, not one that has started operating. This forecast rests on the assumption that the last three days show how the machine will run in May; if ATM3 was only being tested, the forecast is too high, and I would want to confirm that with the bank. A flat forecast cannot show a weekly pattern, and no prediction interval is given because three observations cannot support one.
Each selected model is refitted on all the days of its history through 30 April 2010 (cleaned as above) and used to forecast the 31 days of May. The forecast assumes that the later weekly pattern, with the low day on Tuesday, continues in May.
atm_fc <- map(working_atms, \(k) {
sel <- atm_selected |> filter(ATM == k)
m <- sel$.model
# refit only the selected model, on all the days of its history through 30 April
final_fit <- if (sel$history == "whole year") {
fit_atm(atm_clean |> filter(ATM == k), which = m)
} else {
fit_atm_recent(atm_clean |> filter(ATM == k, DATE >= recent_start), which = m)
}
final_fit |> forecast(h = 31) |>
hilo(level = 95) |>
as_tibble() |>
transmute(ATM = k, DATE, model = m, model_value = .mean, forecast = pmax(.mean, 0), # cash cannot be negative
lower95 = pmax(`95%`$lower, 0), upper95 = `95%`$upper)
}) |> list_rbind()
atm3_fc <- tibble(ATM = "ATM3", DATE = seq(as.Date("2010-05-01"), as.Date("2010-05-31"), by = "day"),
model = "mean of last 3 active days", forecast = mean(atm3_active$Cash),
lower95 = NA_real_, upper95 = NA_real_)
may_forecast <- bind_rows(atm_fc, atm3_fc) |>
arrange(ATM, DATE) |>
mutate(weekday = wday(DATE, label = TRUE), forecast_dollars = forecast * 100)
recent <- atm_clean |> filter(DATE >= as.Date("2010-03-01")) |> as_tibble()
ggplot() +
geom_line(data = recent, aes(DATE, Cash), colour = "gray40") +
geom_ribbon(data = may_forecast |> filter(!is.na(lower95)),
aes(DATE, ymin = lower95, ymax = upper95), fill = "steelblue", alpha = 0.25) +
geom_line(data = may_forecast, aes(DATE, forecast), colour = "steelblue") +
facet_wrap(vars(ATM), ncol = 1, scales = "free_y") +
labs(title = "May 2010 forecast by ATM (history from March 2010)",
y = "Cash (hundreds of dollars)", x = "Date")
The table lists the forecast for the four Tuesdays of May (4, 11, 18 and 25).
may_forecast |>
filter(ATM != "ATM3", weekday == "Tue") |>
select(ATM, DATE, model_value, forecast, lower95, upper95) |>
knitr::kable(digits = 1)
| ATM | DATE | model_value | forecast | lower95 | upper95 |
|---|---|---|---|---|---|
| ATM1 | 2010-05-04 | 5.7 | 5.7 | 0.0 | 52.8 |
| ATM1 | 2010-05-11 | 5.7 | 5.7 | 0.0 | 55.8 |
| ATM1 | 2010-05-18 | 5.7 | 5.7 | 0.0 | 58.6 |
| ATM1 | 2010-05-25 | 5.7 | 5.7 | 0.0 | 61.2 |
| ATM2 | 2010-05-04 | 1.4 | 1.4 | 0.0 | 36.6 |
| ATM2 | 2010-05-11 | 1.8 | 1.8 | 0.0 | 39.4 |
| ATM2 | 2010-05-18 | 1.5 | 1.5 | 0.0 | 47.8 |
| ATM2 | 2010-05-25 | 1.7 | 1.7 | 0.0 | 51.5 |
| ATM4 | 2010-05-04 | 47.1 | 47.1 | 1.8 | 135.9 |
| ATM4 | 2010-05-11 | 51.4 | 51.4 | 0.1 | 169.7 |
| ATM4 | 2010-05-18 | 55.7 | 55.7 | 0.0 | 201.2 |
| ATM4 | 2010-05-25 | 60.0 | 60.0 | 0.0 | 231.4 |
These are the lowest forecast days of each working ATM in the plot
above, so the forecast keeps the Tuesday low day. The May Tuesday values
are much lower than the April holdout forecasts in section A7 (for
example 47.1 to 60.0 against 142.1 to 150.6 for ATM4), presumably
because the final models also see April; the ATM4 values are still above
the Tuesday values of 2.5 to 29.9 in the holdout. The full daily
forecast for every ATM is in the Excel file
Project1_Forecasts.xlsx, sheet ATM_May2010.
Point forecasts and lower interval limits are floored at zero because
cash cannot be negative; the floor shows in the lower95
column of the Tuesday table above for ATM1, ATM2 and the last two ATM4
Tuesdays.
Used: time plots, a weekday heatmap and profile plots, and ACF/PACF for the patterns; models fitted to the whole year compared with models fitted only to the days after the weekly pattern changed; same-weekday median imputation for the missing values and the outlier; Guerrero’s Box-Cox lambda; the four benchmarks; ETS with and without a Box-Cox transformation; STL with ETS; automatic, full-search and manual seasonal ARIMA; holdout accuracy (RMSE and MAE) and the Ljung-Box test to choose between them.
Tried and not kept:
Not used: regression models with weekday dummy variables could describe the weekly pattern directly, but this report compares the time-series methods listed above and does not add regression models. Replacing the weekday medians with a model-based interpolation was not done for the same reason. KNN imputation, which Kuhn and Johnson describe in section 3.4, was not used: the same-weekday median already summarizes the most comparable days, and only 6 values are replaced. Rolling cross-validation was not used for the ATMs; the comparison uses one holdout of exactly 31 days, the length of the May forecast.
The forecast assumes that the later weekly pattern (Tuesday low day) continues through May. The data cannot show why the pattern changed in February, and a further change would break the forecast. In the April holdout the selected ATM1 and ATM4 models forecast the Tuesday low day far too high (section A7), and the May forecast for ATM4 may still do so; if the later pattern continues, the risk is over-supplying those machines on Tuesdays. The prediction intervals assume uncorrelated, roughly normal residuals, and the residuals for ATM1 and ATM2 are heavy-tailed, so the intervals should be read as approximate. Kuhn and Johnson (section 4.3) warn that a single test set is a weak basis for judging a model. The ATM models are chosen and reported on the same 31-day holdout, so the holdout errors are probably a little optimistic.
The file holds monthly residential power use in kilowatt hours (KWH) from January 1998 to December 2013. The goal is a monthly forecast for 2014.
power_raw <- read_excel("ResidentialCustomerForecastLoad-624.xlsx") |>
mutate(month = yearmonth(`YYYY-MMM`)) |>
select(month, KWH) |>
as_tsibble(index = month)
# rows in the data against the number of months from the first to the last month
tibble(first = min(power_raw$month), last = max(power_raw$month), rows = nrow(power_raw),
months_first_to_last = as.numeric(max(power_raw$month) - min(power_raw$month)) + 1) |>
knitr::kable()
| first | last | rows | months_first_to_last |
|---|---|---|---|
| 1998 Jan | 2013 Dec | 192 | 192 |
power_raw |> filter(is.na(KWH) | KWH < 3e6)
## # A tsibble: 2 x 2 [1M]
## month KWH
## <mth> <dbl>
## 1 2008 Sep NA
## 2 2010 Jul 770523
power_raw |> autoplot(KWH) +
labs(title = "Monthly residential power use", y = "KWH", x = "Month")
The first table shows 192 months from the first to the last month, with no month missing. The second table shows two bad values: September 2008 is missing, and July 2010 is 770,523 KWH, a visible spike downward in the time plot. The series has a strong annual pattern with a summer peak and a winter peak, and in the later years the level and the size of the seasonal swings are larger.
power_raw |> gg_season(KWH, labels = "both") + # labels = "both" is not shown in the book (display only)
labs(title = "Seasonal plot of residential power use", y = "KWH")
power_raw |> gg_subseries(KWH) +
labs(title = "Seasonal subseries plot", y = "KWH")
The seasonal plot shows two peaks a year, a winter peak in January and a summer peak in July and August, with the lowest use in April and May and again in October and November. In the subseries plot, where the blue lines are the monthly means, August (about 8.3 million) and January (about 8.0 million) are the highest. The July panel also shows the outlier as one point far below everything else; the other Julys range from about 6 to 10 million, so 770,523 is most likely an error. The recent increase in level is not spread evenly across the year: since about 2010 the lines for January, February, June, August and December climb the most, April, May and November rise somewhat, and March and October stay roughly flat. The extra use is largest in the winter and summer months, which makes the seasonal swings look larger in the raw plot; the data has no temperature or other information that would explain why.
As in Part A, nothing is deleted, and the very low July value is treated as a possible recording error before anything else, as Kuhn and Johnson advise (section 3.3). Each bad month is replaced by the value of the same calendar month in the previous year. That keeps the seasonal pattern, and it uses only earlier data, so the replacement is available at every forecast origin used later in the cross-validation and nothing from after the month is used. The first table below gives the replaced values, and the second gives the previous and the following year of each month for comparison.
sep_2008 <- power_raw$KWH[power_raw$month == yearmonth("2007 Sep")] # the same month one year earlier
jul_2010 <- power_raw$KWH[power_raw$month == yearmonth("2009 Jul")]
# replace the two bad months with the values computed above (data preparation)
power <- power_raw |>
mutate(KWH_original = KWH,
KWH = case_when(month == yearmonth("2008 Sep") ~ sep_2008,
month == yearmonth("2010 Jul") ~ jul_2010,
TRUE ~ KWH))
# the same series with the two bad months set to missing, used to score the forecasts on observed values only
power_observed <- power_raw |>
mutate(KWH = if_else(month == yearmonth("2010 Jul"), NA_real_, KWH))
# the two replaced months, then the previous and following years behind each replacement
power |> filter(month %in% c(yearmonth("2008 Sep"), yearmonth("2010 Jul"))) |>
as_tibble() |>
knitr::kable(digits = 0, format.args = list(big.mark = ","))
| month | KWH | KWH_original |
|---|---|---|
| 2008 Sep | 7,666,970 | NA |
| 2010 Jul | 7,713,260 | 770,523 |
power_raw |> filter(month %in% yearmonth(c("2007 Sep", "2009 Sep", "2009 Jul", "2011 Jul"))) |>
as_tibble() |>
knitr::kable(digits = 0, format.args = list(big.mark = ","))
| month | KWH |
|---|---|
| 2007 Sep | 7,666,970 |
| 2009 Jul | 7,713,260 |
| 2009 Sep | 7,583,146 |
| 2011 Jul | 10,093,343 |
September 2007 and September 2009 are close, so the September replacement is reasonable. For July the following year (2011) is much higher than the previous year (2009), so the July replacement is probably too low and is only an estimate. The 770,523 value is a suspected error, not a confirmed typo, so it is replaced with a seasonal estimate and not with a corrected version of itself.
The cleaned series is decomposed with STL, and its ACF is shown, to see how strong the trend and the seasonal pattern are and how much differencing is needed.
power |> model(STL(KWH ~ season(window = 11), robust = TRUE)) |>
components() |> autoplot() +
labs(title = "STL decomposition of residential KWH")
power |> features(KWH, feat_stl) |> select(trend_strength, seasonal_strength_year) |>
knitr::kable(digits = 2)
| trend_strength | seasonal_strength_year |
|---|---|
| 0.56 | 0.87 |
power |> ACF(KWH, lag_max = 48) |> autoplot() +
labs(title = "ACF of residential KWH")
The seasonal component is strong (0.87) and the trend is moderate (0.56). The trend stays between about 5.9 and 6.5 million until 2009, climbs to about 7.4 million in 2011 and 2012, and ends near 7.3 million. The seasonal component keeps about the same size over the whole period in the STL plot, so the larger swings in the raw plot after 2010 appear to come mostly from the trend and from larger remainders, not from a growing seasonal pattern. The evidence for multiplicative seasonality is modest, so additive and multiplicative ETS forms and a Box-Cox version are compared on accuracy and not assumed. The remainder is largest at the end of the series, in December 2013 (about 9.6 million in the seasonal plot in B1, while the earlier Decembers are all below about 8.3 million). Nothing shows that this value is an error, so it is kept as a real observation. The ACF has a scalloped shape with large positive spikes at lags 12, 24, 36 and 48 that shrink only slowly (from about 0.75 to 0.5), and positive spikes of about 0.5 to 0.6 at lags 6 and 18 that reflect the two peaks a year (the spikes at lags 3, 9 and 15 are strongly negative, about -0.45, for the same reason). That slow decay at the seasonal lags is what a trending, seasonal series looks like, so a seasonal difference, and possibly a first difference, will be needed.
The last 12 months (2013) are held out, so the models are trained on 1998 to 2012.
power_train <- power |> filter(year(month) <= 2012)
power_test <- power |> filter(year(month) == 2013)
power_train |> features(KWH, guerrero) |> knitr::kable(digits = 3)
| lambda_guerrero |
|---|
| -0.169 |
power_train |> features(KWH, list(unitroot_kpss, unitroot_nsdiffs, unitroot_ndiffs)) |>
knitr::kable(digits = 3)
| kpss_stat | kpss_pvalue | nsdiffs | ndiffs |
|---|---|---|---|
| 1.17 | 0.01 | 1 | 1 |
The Guerrero lambda is negative, which would be a strong transformation, so Box-Cox models are tested rather than assumed. The series needs one seasonal difference. After that I check whether another difference is needed and look at the ACF and PACF to choose manual ARIMA candidates.
power_train |> features(difference(KWH, 12), unitroot_kpss) |> knitr::kable(digits = 3)
| kpss_stat | kpss_pvalue |
|---|---|
| 0.101 | 0.1 |
power_train |> gg_tsdisplay(difference(KWH, 12), plot_type = "partial", lag_max = 36) +
labs(title = "Residential KWH after one seasonal difference (lag 12)")
## Warning: Removed 12 rows containing missing values or values outside the scale range
## (`geom_line()`).
## Warning: Removed 12 rows containing missing values or values outside the scale range
## (`geom_point()`).
After the seasonal difference the KPSS test no longer rejects stationarity, so no further difference is needed. The ACF has a large negative spike at lag 12, and the PACF has negative spikes at lags 12 and 24 that shrink, which is the pattern of a seasonal moving average term, so (0,1,1) in the seasonal part. The warnings about removed rows are the first 12 values lost to the seasonal difference. Both plots also show a positive spike at lag 1, which a non-seasonal AR(1) or MA(1) term can capture, and a smaller one at lag 3. The manual candidates are ARIMA(1,0,0)(0,1,1)[12], ARIMA(0,0,1)(0,1,1)[12] and ARIMA(2,0,0)(0,1,1)[12]. ARIMA(1,0,0)(1,1,0)[12] is added as a contrast, a seasonal AR term in place of the seasonal MA term. They are fitted next to the automatic choices.
| What the exploration shows | Where it is shown | What it led to |
|---|---|---|
| A strong annual pattern with two peaks a year | B1 seasonal and subseries plots, B3 seasonal strength | Seasonal naive as the benchmark, and seasonal ETS and seasonal ARIMA models |
| The level rises after 2009 and the swings look larger in the later years | B1 time plot, B3 STL plot | Box-Cox ETS compared with plain ETS, and drift terms in the ARIMA models |
| One seasonal difference is needed, and the ACF and PACF after it show a seasonal moving average pattern | B4 tests and plots | Manual ARIMA candidates with (0,1,1) in the seasonal part, next to the automatic and full-search ARIMA |
| One missing month and one suspected recording error | B1 tables, B2 | Replaced with the same month of the previous year, and not scored as observed values |
lambda_power <- power_train |> features(KWH, guerrero) |> pull(lambda_guerrero)
power_fit <- power_train |> model(
mean = MEAN(KWH),
naive = NAIVE(KWH),
snaive = SNAIVE(KWH),
drift = RW(KWH ~ drift()),
ets = ETS(KWH),
ets_boxcox = ETS(box_cox(KWH, lambda_power)), # Box-Cox inside ETS() is only mentioned in exercise 8.13 of the book
stl_ets = decomposition_model(STL(KWH ~ season(window = 11), robust = TRUE),
ETS(season_adjust ~ season("N"))),
arima_auto = ARIMA(KWH),
arima_search = ARIMA(KWH, stepwise = FALSE, approx = FALSE),
arima_100_011 = ARIMA(KWH ~ pdq(1, 0, 0) + PDQ(0, 1, 1)),
arima_001_011 = ARIMA(KWH ~ pdq(0, 0, 1) + PDQ(0, 1, 1)),
arima_200_011 = ARIMA(KWH ~ pdq(2, 0, 0) + PDQ(0, 1, 1)),
arima_100_110 = ARIMA(KWH ~ pdq(1, 0, 0) + PDQ(1, 1, 0))
)
# list the ARIMA and ETS specifications as a table (display only)
power_fit |> select(starts_with("arima"), ets, ets_boxcox) |>
pivot_longer(everything(), names_to = "model", values_to = "specification") |>
knitr::kable()
| model | specification |
|---|---|
| arima_auto | <ARIMA(0,0,1)(2,1,0)[12] w/ drift> |
| arima_search | <ARIMA(3,0,1)(2,1,0)[12] w/ drift> |
| arima_100_011 | <ARIMA(1,0,0)(0,1,1)[12] w/ drift> |
| arima_001_011 | <ARIMA(0,0,1)(0,1,1)[12] w/ drift> |
| arima_200_011 | <ARIMA(2,0,0)(0,1,1)[12] w/ drift> |
| arima_100_110 | <ARIMA(1,0,0)(1,1,0)[12] w/ drift> |
| ets | <ETS(M,N,M)> |
| ets_boxcox | <ETS(M,N,A)> |
power_accuracy <- accuracy(forecast(power_fit, h = 12), power) |>
as_tibble() |> select(.model, RMSE, MAE, MASE) |>
left_join(ljung_table(power_fit, 24), by = ".model")
power_accuracy |>
arrange(RMSE) |>
select(model = .model, RMSE, MAE, MASE, ljung_box_p = lb_pvalue) |>
knitr::kable(digits = 3, format.args = list(big.mark = ","))
| model | RMSE | MAE | MASE | ljung_box_p |
|---|---|---|---|---|
| stl_ets | 947,371.5 | 583,774.9 | 0.961 | 0.046 |
| arima_search | 949,335.1 | 637,187.3 | 1.049 | 0.994 |
| arima_auto | 968,992.5 | 655,243.8 | 1.078 | 0.179 |
| arima_100_011 | 980,554.1 | 600,155.0 | 0.988 | 0.432 |
| arima_200_011 | 982,271.4 | 601,409.0 | 0.990 | 0.346 |
| arima_001_011 | 994,084.6 | 613,661.1 | 1.010 | 0.465 |
| arima_100_110 | 996,948.2 | 719,593.0 | 1.184 | 0.002 |
| snaive | 1,035,537.6 | 618,605.6 | 1.018 | 0.000 |
| ets_boxcox | 1,049,814.2 | 686,014.3 | 1.129 | 0.014 |
| ets | 1,111,268.4 | 722,113.2 | 1.189 | 0.029 |
| naive | 1,861,178.6 | 1,501,420.0 | 2.471 | 0.000 |
| drift | 1,865,709.5 | 1,502,832.8 | 2.474 | 0.000 |
| mean | 1,938,591.6 | 1,531,580.0 | 2.521 | 0.000 |
The models without a seasonal component (mean, naive, drift) are far behind. RMSE and MAE do not rank every model the same way: the ARIMA(1,0,0)(0,1,1) model has a lower MAE (600,155) than the full-search ARIMA (637,187), although its RMSE is higher (980,554 against 949,335). RMSE gives more weight to large misses, so a model with a few large errors ranks lower on RMSE than on MAE. The seasonal naive model, which just repeats last year, is strong for this series, and the best models improve on it by only about 5 to 9 percent in RMSE. A single test year is a fragile basis for choosing among models that close, as Kuhn and Johnson also say of a single test set (section 4.3), and 2013 ends with an unusually high December (section B3). The holdout is therefore repeated across several years with time series cross-validation: the training data grows by 12 months at a time (starting with 120 months) and each fit forecasts the next 12 months. The Box-Cox ETS is left out of the cross-validation because its lambda would have to be estimated again in every fold; it is compared on the 2013 holdout only, and the two bad months are not scored.
power_cv <- power |>
stretch_tsibble(.init = 120, .step = 12) |>
model(snaive = SNAIVE(KWH),
ets = ETS(KWH),
stl_ets = decomposition_model(STL(KWH ~ season(window = 11), robust = TRUE),
ETS(season_adjust ~ season("N"))),
arima_auto = ARIMA(KWH),
arima_search = ARIMA(KWH, stepwise = FALSE, approx = FALSE),
arima_100_011 = ARIMA(KWH ~ pdq(1, 0, 0) + PDQ(0, 1, 1)),
arima_001_011 = ARIMA(KWH ~ pdq(0, 0, 1) + PDQ(0, 1, 1)),
arima_200_011 = ARIMA(KWH ~ pdq(2, 0, 0) + PDQ(0, 1, 1)),
arima_100_110 = ARIMA(KWH ~ pdq(1, 0, 0) + PDQ(1, 1, 0))) |>
forecast(h = 12) |>
accuracy(power_observed) |> # the two bad months are not scored
as_tibble() |> select(.model, cv_RMSE = RMSE, cv_MAE = MAE)
## Warning: The future dataset is incomplete, incomplete out-of-sample data will be treated as missing.
## 12 observations are missing between 2014 Jan and 2014 Dec
power_cv |>
mutate(percent_above_best = 100 * (cv_RMSE / min(cv_RMSE) - 1)) |>
arrange(cv_RMSE) |>
select(model = .model, cv_RMSE, cv_MAE, percent_above_best) |>
knitr::kable(digits = 1, format.args = list(big.mark = ","))
| model | cv_RMSE | cv_MAE | percent_above_best |
|---|---|---|---|
| arima_auto | 779,327.5 | 553,316.6 | 0.0 |
| arima_100_011 | 804,664.4 | 561,626.5 | 3.3 |
| arima_search | 805,174.7 | 577,103.6 | 3.3 |
| arima_001_011 | 805,464.8 | 556,452.8 | 3.4 |
| arima_200_011 | 807,585.6 | 563,291.5 | 3.6 |
| arima_100_110 | 808,341.6 | 600,242.9 | 3.7 |
| stl_ets | 848,696.5 | 605,120.6 | 8.9 |
| ets | 890,273.1 | 626,391.0 | 14.2 |
| snaive | 939,756.7 | 695,725.9 | 20.6 |
The last stretch of data ends in December 2013 and has no future data to test on, so it is ignored; that is what the warning above says.
Selection rule. For Part B the choice rests on the cross-validated RMSE, because it uses several forecast origins instead of one. Among the models whose residuals also pass the Ljung-Box test in the 2013 fit, I take the one with the lowest cross-validated RMSE. The next-best model in the table above is about 3% higher, so the 1% tolerance step used in Part A would not change this choice.
power_selected <- power_accuracy |>
left_join(power_cv, by = ".model") |>
filter(lb_pvalue >= 0.05, !is.na(cv_RMSE)) |> # keep models that pass the residual test
slice_min(cv_RMSE, n = 1) # lowest cross-validated RMSE
power_selected |> select(model = .model, cv_RMSE, holdout_RMSE = RMSE, ljung_box_p = lb_pvalue) |>
knitr::kable(digits = 3, format.args = list(big.mark = ","))
| model | cv_RMSE | holdout_RMSE | ljung_box_p |
|---|---|---|---|
| arima_auto | 779,327.5 | 968,992.5 | 0.179 |
power_fit |> select(all_of(power_selected$.model)) |> gg_tsresiduals() +
labs(title = paste("Residuals of", power_selected$.model))
The residuals of the selected model, fitted on the training years, have mean near zero and no obvious trend, and the histogram is close to bell-shaped. The ACF has one spike clearly outside the dashed lines, at lag 3, which I treat as a small remaining weakness; the Ljung-Box test over 24 lags does not reject at the 5% level (p = 0.179). The spread of the residuals is larger from 2010 on, so the prediction intervals are probably a little too narrow for the most recent years.
The single 2013 holdout and the cross-validation do not agree on which model is best: STL with ETS has the lowest holdout RMSE but its residuals narrowly fail the Ljung-Box test (p = 0.046), the full-search ARIMA is next, and the automatic ARIMA wins the cross-validation. The cross-validation covers several forecast years and gives a more reliable comparison than a single split, so it decides, and the other ARIMA models are within about 4% of the automatic one in the cross-validation table. That is not a clear difference.
The selected model is refitted on all data through December 2013 and
forecasts the twelve months of 2014. The model is the automatic
ARIMA() procedure, so its order is chosen again on the
longer data; the specification it picks is printed first.
# the selected procedure only; its order is chosen again on the longer data
stopifnot(power_selected$.model == "arima_auto")
power_final <- power |> model(arima_auto = ARIMA(KWH))
power_final # the specification the selected model chooses on all the data
## # A mable: 1 x 1
## arima_auto
## <model>
## 1 <ARIMA(0,0,4)(2,1,0)[12] w/ drift>
power_fc <- power_final |> forecast(h = 12)
power_forecast <- power_fc |>
hilo(level = 95) |>
as_tibble() |>
transmute(month, model = power_selected$.model, forecast_KWH = .mean,
lower95 = `95%`$lower, upper95 = `95%`$upper)
power_forecast |> knitr::kable(digits = 0, format.args = list(big.mark = ","))
| month | model | forecast_KWH | lower95 | upper95 |
|---|---|---|---|---|
| 2014 Jan | arima_auto | 10,291,438 | 9,084,135 | 11,498,741 |
| 2014 Feb | arima_auto | 8,607,148 | 7,335,158 | 9,879,137 |
| 2014 Mar | arima_auto | 7,159,784 | 5,887,058 | 8,432,509 |
| 2014 Apr | arima_auto | 5,833,040 | 4,533,397 | 7,132,683 |
| 2014 May | arima_auto | 5,942,376 | 4,639,971 | 7,244,781 |
| 2014 Jun | arima_auto | 8,198,346 | 6,895,941 | 9,500,751 |
| 2014 Jul | arima_auto | 9,474,812 | 8,172,407 | 10,777,217 |
| 2014 Aug | arima_auto | 9,969,550 | 8,667,145 | 11,271,955 |
| 2014 Sep | arima_auto | 8,485,269 | 7,182,864 | 9,787,674 |
| 2014 Oct | arima_auto | 5,870,010 | 4,567,605 | 7,172,415 |
| 2014 Nov | arima_auto | 6,145,967 | 4,843,562 | 7,448,372 |
| 2014 Dec | arima_auto | 8,427,924 | 7,125,519 | 9,730,329 |
power_fc |> autoplot(power |> filter(year(month) >= 2008), level = 95) +
labs(title = "Residential power use: 2014 forecast", y = "KWH", x = "Month")
# average monthly use in the last observed year and in the forecast year
tibble(year = c("2013 (actual)", "2014 (forecast)"),
mean_KWH = c(mean(power_test$KWH), mean(power_forecast$forecast_KWH))) |>
knitr::kable(digits = 0, format.args = list(big.mark = ","))
| year | mean_KWH |
|---|---|
| 2013 (actual) | 7,618,335 |
| 2014 (forecast) | 7,867,139 |
# residual check of the final fit on all the data (the earlier residual plot is from the fit on the training years)
power_final |> gg_tsresiduals() + labs(title = "Residuals of the final fit on all the data")
ljung_table(power_final, 24) |> knitr::kable(digits = 3)
| .model | lb_pvalue |
|---|---|
| arima_auto | 0.711 |
The forecast keeps the shape of the earlier years: a peak in January, another in July and August, and the lowest use in April, May, October and November. The 2014 average is higher than the 2013 average in the last table, which is consistent with the drift term of the model carrying the rise in level into 2014. The 95% intervals are about 2.4 to 2.6 million KWH wide, for example January from about 9.1 to 11.5 million.
Limitations of the 2014 forecast. The replacements for September 2008 and July 2010 are estimates, and December 2013 is an unusually high last value that carries weight in the refit. The order chosen on all the data, ARIMA(0,0,4)(2,1,0)[12] with drift, is not the one validated on the 2013 holdout, ARIMA(0,0,1)(2,1,0)[12], because the automatic procedure chooses the order again on the longer data. The residuals of the final fit in the plot above show no spike outside the dashed lines and the Ljung-Box test does not reject (p = 0.711), but their spread is larger from 2010 on and the last residual, for December 2013, is the largest, about 2.5 million, so the intervals are probably too narrow. The model uses only the history of the series, so it cannot anticipate weather or economic change.
Used: seasonal and subseries plots, seasonal differencing with KPSS and ACF/PACF to pick manual candidates, replacement of the missing month and the outlier with the same month of the previous year, Guerrero’s lambda, the benchmarks, ETS (with and without Box-Cox), STL with ETS, automatic, full-search and manual seasonal ARIMA, a one-year holdout and time series cross-validation, and the Ljung-Box test.
Tried and not kept: the Box-Cox ETS model (the Guerrero lambda is negative (-0.169), which would be a strong transformation; in the 2013 holdout table the Box-Cox ETS has a lower RMSE than the plain ETS, 1,049,814 against 1,111,268, but its residuals fail the Ljung-Box test (p = 0.014) and it stays behind the seasonal ARIMA models); the benchmarks other than seasonal naive, which cannot follow the seasonal pattern.
Not used: model-based interpolation for the two bad months, and regression models for the upward drift in level after 2010. The report compares the time-series methods listed above and does not add either. The ARIMA models include a drift term when the data call for it, and that is how the rise in level is carried into 2014.
Two files hold water-flow readings from two pipes. The goal is to put each on an hourly sequence, taking the mean of the readings within an hour, to decide whether the hourly series is stationary and can be forecast, and if so to forecast one week (168 hours) ahead.
read_pipe <- function(file) {
read_excel(file) |>
rename(date_time = `Date Time`, flow = WaterFlow) |>
mutate(date_time = as.POSIXct(round(date_time * 86400), origin = "1899-12-30", tz = "UTC")) # Excel day numbers to date-times
}
pipes_raw <- bind_rows(pipe1 = read_pipe("Waterflow_Pipe1.xlsx"),
pipe2 = read_pipe("Waterflow_Pipe2.xlsx"), .id = "pipe")
# size, date range, missing values and spacing (in minutes) of the readings for each pipe
pipes_raw |>
group_by(pipe) |>
mutate(gap_minutes = as.numeric(difftime(date_time, lag(date_time), units = "mins"))) |>
summarise(rows = n(), first = format(min(date_time), "%Y-%m-%d %H:%M"), last = format(max(date_time), "%Y-%m-%d %H:%M"),
missing = sum(is.na(flow)), dup_times = sum(duplicated(date_time)),
min_gap = min(gap_minutes, na.rm = TRUE), med_gap = median(gap_minutes, na.rm = TRUE),
max_gap = max(gap_minutes, na.rm = TRUE)) |>
knitr::kable(digits = 1)
| pipe | rows | first | last | missing | dup_times | min_gap | med_gap | max_gap |
|---|---|---|---|---|---|---|---|---|
| pipe1 | 1000 | 2015-10-23 00:24 | 2015-11-01 23:35 | 0 | 0 | 0 | 10 | 82.1 |
| pipe2 | 1000 | 2015-10-23 01:00 | 2015-12-03 16:00 | 0 | 0 | 60 | 60 | 60.0 |
Both files have 1,000 readings, with no missing values and no repeated times. The readings of Pipe 1 are spaced irregularly, from almost no time apart to 82.1 minutes, with a median of 10 minutes, and they cover about ten days (23 October to 1 November 2015). The readings of Pipe 2 are exactly 60 minutes apart from 23 October to 3 December 2015, so it is already an hourly series. The two pipes therefore have different time stamps and different lengths of history.
pipes_raw |>
group_by(pipe) |>
summarise(mean = mean(flow), sd = sd(flow), min = min(flow), median = median(flow), max = max(flow)) |>
knitr::kable(digits = 1)
| pipe | mean | sd | min | median | max |
|---|---|---|---|---|---|
| pipe1 | 19.9 | 8.2 | 1.1 | 19.9 | 38.9 |
| pipe2 | 39.6 | 16.0 | 1.9 | 39.7 | 78.3 |
ggplot(pipes_raw, aes(date_time, flow)) +
geom_line(colour = "gray60", linewidth = 0.3) +
geom_point(size = 0.6, colour = "steelblue") +
facet_wrap(vars(pipe), scales = "free_x", ncol = 1) +
labs(title = "Raw readings of each pipe, as recorded", x = "Date and time", y = "Water flow")
The average flow of Pipe 1 is 19.9 and that of Pipe 2 is 39.6, so Pipe 2 carries about twice as much, and it also varies more (standard deviation 16.0 against 8.2). All readings are positive, and none is extreme: the largest is 38.9 for Pipe 1 and 78.3 for Pipe 2. In the graph the readings of both pipes scatter around a steady level, with no visible trend or cycle.
The hour of each reading is found by rounding its time down to the hour, and the readings in the same hour are averaged. For Pipe 1 the table counts how many hours have 1, 2, 3 and so on readings, and lists the hours of its span that have no reading.
pipe1_hourly_raw <- pipes_raw |>
filter(pipe == "pipe1") |>
mutate(hour = floor_date(date_time, "hour")) |>
group_by(hour) |>
summarise(flow = mean(flow), readings = n(), .groups = "drop")
pipe1_hourly_raw |> count(readings, name = "hours") |> knitr::kable()
| readings | hours |
|---|---|
| 1 | 18 |
| 2 | 31 |
| 3 | 40 |
| 4 | 46 |
| 5 | 47 |
| 6 | 26 |
| 7 | 11 |
| 8 | 8 |
| 9 | 6 |
| 10 | 3 |
# every hour from the first to the last reading, and the hours without a reading
pipe1_hours <- tibble(hour = seq(min(pipe1_hourly_raw$hour), max(pipe1_hourly_raw$hour), by = "hour"))
pipe1_hours |> anti_join(pipe1_hourly_raw, by = "hour") |> knitr::kable()
| hour |
|---|
| 2015-10-27 17:00:00 |
| 2015-10-28 00:00:00 |
| 2015-11-01 05:00:00 |
| 2015-11-01 09:00:00 |
Most hours of Pipe 1 have between 3 and 6 readings, and the number runs from 1 to 10 (first table). Four hours in its span (23 October to 1 November) have no reading (second table), and none of them is next to another missing hour.
# an isolated missing hour gets the average of the hour before and the hour after
pipe1_filled <- pipe1_hours |>
left_join(pipe1_hourly_raw, by = "hour") |>
mutate(before = lag(flow), after = lead(flow),
filled = is.na(flow),
flow = if_else(filled, (before + after) / 2, flow))
pipe1_filled |> filter(filled) |> select(hour, before, after, flow) |> knitr::kable(digits = 1)
| hour | before | after | flow |
|---|---|---|---|
| 2015-10-27 17:00:00 | 25.9 | 19.1 | 22.5 |
| 2015-10-28 00:00:00 | 13.5 | 24.1 | 18.8 |
| 2015-11-01 05:00:00 | 16.9 | 16.8 | 16.8 |
| 2015-11-01 09:00:00 | 15.1 | 15.7 | 15.4 |
pipe1_ts <- pipe1_filled |> select(hour, flow) |> as_tsibble(index = hour)
# the same series with the filled hours set to missing, so they are not scored as observed values
pipe1_scoring <- pipe1_filled |>
mutate(flow = if_else(filled, NA_real_, flow)) |>
select(hour, flow) |>
as_tsibble(index = hour)
pipe2_ts <- pipes_raw |>
filter(pipe == "pipe2") |>
transmute(hour = floor_date(date_time, "hour"), flow) |>
as_tsibble(index = hour)
# hours in the span against hours with a value, for each pipe
tibble(pipe = c("pipe1", "pipe2"),
hours_in_span = c(length(seq(min(pipe1_ts$hour), max(pipe1_ts$hour), by = "hour")),
length(seq(min(pipe2_ts$hour), max(pipe2_ts$hour), by = "hour"))),
hours_with_value = c(nrow(pipe1_ts), nrow(pipe2_ts))) |>
knitr::kable()
| pipe | hours_in_span | hours_with_value |
|---|---|---|
| pipe1 | 240 | 240 |
| pipe2 | 1000 | 1000 |
Each missing hour is filled with the average of the hour before and the hour after; the first table shows the three values for each. After the filling, Pipe 1 has a value in every one of its 240 hours, and Pipe 2 has a value in every one of its 1,000 hours (second table), so both series are complete and regular. The hourly means of Pipe 1 rest on different numbers of readings, so an hour with one or two readings may give a less precise hourly average than one with six; I keep this in mind but do not adjust for it.
The graphs show the two hourly series with their averages, and the autocorrelation of each (48 hours for Pipe 1, a week for Pipe 2).
hourly_both <- bind_rows(pipe1 = as_tibble(pipe1_ts), pipe2 = as_tibble(pipe2_ts), .id = "pipe")
ggplot(hourly_both, aes(hour, flow)) +
geom_line(colour = "gray50", linewidth = 0.4) +
geom_hline(data = hourly_both |> group_by(pipe) |> summarise(average = mean(flow)), # not in the book; draws each series' average (display only)
aes(yintercept = average), colour = "firebrick", linetype = "dashed") +
facet_wrap(vars(pipe), scales = "free", ncol = 1) +
labs(title = "Hourly water flow with each series' average (red dashed)", x = "Hour", y = "Water flow")
pipe1_ts |> ACF(flow, lag_max = 48) |> autoplot() + labs(title = "Pipe 1: ACF, 48 hours")
pipe2_ts |> ACF(flow, lag_max = 168) |> autoplot() + labs(title = "Pipe 2: ACF, 168 hours")
Both hourly series move around a constant average with no trend, and their spread looks the same from start to end. The ACF of Pipe 1 stays at or inside the dashed lines at every lag up to 48 hours. The ACF of Pipe 2 has a few spikes slightly outside the lines, not at a regular spacing; with 168 lags, about 1 in 20 can fall outside by chance.
The tables give the average and standard deviation of the hourly values, the KPSS statistic and the number of differences suggested, the Ljung-Box test for white noise at lags 10 and 24, and the strength of a 24-hour pattern.
pipes_ts <- list(pipe1 = pipe1_ts, pipe2 = pipe2_ts)
# average and standard deviation of the hourly values of each pipe
map(pipes_ts, \(x) x |> as_tibble() |> summarise(mean = mean(flow), sd = sd(flow))) |>
list_rbind(names_to = "pipe") |> knitr::kable(digits = 1)
| pipe | mean | sd |
|---|---|---|
| pipe1 | 19.9 | 4.2 |
| pipe2 | 39.6 | 16.0 |
map(pipes_ts, \(x) x |> features(flow, list(unitroot_kpss, unitroot_ndiffs))) |>
list_rbind(names_to = "pipe") |> knitr::kable(digits = 3)
| pipe | kpss_stat | kpss_pvalue | ndiffs |
|---|---|---|---|
| pipe1 | 0.193 | 0.1 | 0 |
| pipe2 | 0.105 | 0.1 | 0 |
map(c(10, 24), \(lag) {
map(pipes_ts, \(x) x |> features(flow, ljung_box, lag = lag)) |> list_rbind(names_to = "pipe") |>
mutate(lag = lag)
}) |> list_rbind() |> select(pipe, lag, lb_stat, lb_pvalue) |> knitr::kable(digits = 3)
| pipe | lag | lb_stat | lb_pvalue |
|---|---|---|---|
| pipe1 | 10 | 7.755 | 0.653 |
| pipe2 | 10 | 6.458 | 0.775 |
| pipe1 | 24 | 22.034 | 0.577 |
| pipe2 | 24 | 32.474 | 0.116 |
map(pipes_ts, \(x) x |> features(flow, feat_stl)) |>
list_rbind(names_to = "pipe") |>
select(pipe, trend_strength, seasonal_strength_day) |> knitr::kable(digits = 2)
| pipe | trend_strength | seasonal_strength_day |
|---|---|---|
| pipe1 | 0.04 | 0.21 |
| pipe2 | 0.08 | 0.23 |
The KPSS test does not reject stationarity for either pipe (p-values of 0.1 or more), and no differencing is suggested. The Ljung-Box tests do not reject white noise at lag 10 or 24 for either pipe (all p-values above 0.1), so the tests did not detect autocorrelation at those lags. The trend strength is close to zero (0.04 and 0.08) and the strength of a 24-hour pattern is weak (0.21 and 0.23). The diagnostics are consistent with stationarity, and both series look like white noise around a constant mean: there is no trend or cycle to forecast, only the level. A series like this can be forecast, but only as a level: the forecast is the average.
| What the exploration shows | Where it is shown | What it led to |
|---|---|---|
| Pipe 1 readings are irregular and several share an hour; Pipe 2 is already hourly | C1 to C3 tables | Hourly means for both pipes |
| Four isolated missing hours in Pipe 1 | C3 tables | Filled from the neighboring hours, and not scored as observed values |
| Little trend, weak 24-hour pattern, little detected autocorrelation, and KPSS does not reject stationarity | C4 tables and plots | The mean and small ARIMA models as the main candidates, next to the benchmarks and ETS |
The same kinds of models as before are compared: the four benchmarks, ETS, the automatic ARIMA, and three small ARIMA models without seasonal terms. ARIMA(0,0,0) is white noise around a constant, and ARIMA(1,0,0) and ARIMA(0,0,1) add one autocorrelation term. The holdout is the last 48 hours for Pipe 1, which has only 240 hours of history, and the last 168 hours (the length of the forecast) for Pipe 2. The models are compared by RMSE and MAE on the holdout and by the Ljung-Box test at lag 24 on the residuals, and the selection rule is the Part A rule (models that pass the residual test, lowest holdout RMSE) with one addition from Kuhn and Johnson (section 4.6), who advise choosing a simpler model whose performance is within a small tolerance of the best. Here the tolerance is 1%, a threshold I chose (their example accepts a 4% loss), and the rule is applied across model families, not to the tuning parameters of one model. Among the models that pass the residual test, those within 1% of the lowest RMSE are kept, and the simplest of them is chosen; the order from simplest to most complex is the plain mean, ARIMA(0,0,0), ETS, the ARIMA models with one term, and the automatic ARIMA.
holdout_hours <- c(pipe1 = 48, pipe2 = 168)
fit_pipe <- function(series, which = NULL) {
specs <- list(
mean = MEAN(flow),
naive = NAIVE(flow),
snaive = SNAIVE(flow),
drift = RW(flow ~ drift()),
ets = ETS(flow),
arima_auto = ARIMA(flow),
arima_000 = ARIMA(flow ~ pdq(0, 0, 0) + PDQ(0, 0, 0)),
arima_100 = ARIMA(flow ~ pdq(1, 0, 0) + PDQ(0, 0, 0)),
arima_001 = ARIMA(flow ~ pdq(0, 0, 1) + PDQ(0, 0, 0))
)
if (is.null(which)) which <- names(specs)
do.call(model, c(list(series), specs[which])) # fit only the models named in `which`
}
pipe_scoring <- list(pipe1 = pipe1_scoring, pipe2 = pipes_ts$pipe2) # holdout scored on observed hours only
pipe_train <- map(names(pipes_ts), \(k) {
x <- pipes_ts[[k]]
x |> filter(hour <= max(hour) - hours(holdout_hours[[k]]))
}) |> set_names(names(pipes_ts))
pipe_fits <- map(pipe_train, fit_pipe)
pipe_accuracy <- map(names(pipes_ts), \(k) {
accuracy(forecast(pipe_fits[[k]], h = holdout_hours[[k]]), pipe_scoring[[k]]) |>
as_tibble() |> select(.model, RMSE, MAE) |>
left_join(ljung_table(pipe_fits[[k]], 24), by = ".model") |>
mutate(pipe = k)
}) |> list_rbind()
pipe_accuracy |> arrange(pipe, RMSE) |>
select(pipe, model = .model, RMSE, MAE, ljung_box_p = lb_pvalue) |> knitr::kable(digits = 3)
| pipe | model | RMSE | MAE | ljung_box_p |
|---|---|---|---|---|
| pipe1 | arima_100 | 3.947 | 2.994 | 0.454 |
| pipe1 | arima_auto | 3.947 | 2.994 | 0.454 |
| pipe1 | arima_001 | 3.948 | 2.994 | 0.447 |
| pipe1 | arima_000 | 3.951 | 2.999 | 0.592 |
| pipe1 | mean | 3.951 | 2.999 | 0.592 |
| pipe1 | ets | 3.951 | 2.999 | 0.592 |
| pipe1 | naive | 6.162 | 5.112 | 0.000 |
| pipe1 | snaive | 6.362 | 5.145 | 0.000 |
| pipe1 | drift | 7.196 | 6.314 | 0.000 |
| pipe2 | arima_auto | 16.638 | 13.676 | 0.235 |
| pipe2 | ets | 16.733 | 13.749 | 0.079 |
| pipe2 | arima_100 | 16.733 | 13.750 | 0.090 |
| pipe2 | arima_000 | 16.733 | 13.750 | 0.079 |
| pipe2 | mean | 16.733 | 13.750 | 0.079 |
| pipe2 | arima_001 | 16.733 | 13.750 | 0.090 |
| pipe2 | naive | 16.736 | 13.761 | 0.000 |
| pipe2 | drift | 16.736 | 13.892 | 0.000 |
| pipe2 | snaive | 21.570 | 17.605 | 0.000 |
# the specifications chosen by the automatic ARIMA and ETS
map(pipe_fits, \(f) f |> select(ets, arima_auto) |> pivot_longer(everything(), names_to = "model", values_to = "specification")) |>
list_rbind(names_to = "pipe") |> knitr::kable()
| pipe | model | specification |
|---|---|---|
| pipe1 | ets | <ETS(M,N,N)> |
| pipe1 | arima_auto | <ARIMA(1,0,0) w/ mean> |
| pipe2 | ets | <ETS(M,N,N)> |
| pipe2 | arima_auto | <ARIMA(0,0,0)(0,0,1)[24] w/ mean> |
For both pipes the naive, seasonal naive and drift forecasts are no better than the mean, and for Pipe 1 they are clearly worse (RMSE 6.16, 6.36 and 7.20 against 3.95). They also fail the residual test (p about 0), because following the last value or the last day adds noise to a series with little detected autocorrelation (section C4). The mean, ARIMA(0,0,0) and ETS give the same RMSE for each pipe (3.95 for Pipe 1 and 16.73 for Pipe 2); the ETS(M,N,N) model in the second table is in effect the same flat forecast. The other ARIMA models differ from them by less than 1% (3.947 against 3.951 for Pipe 1, and 16.638 against 16.733 for Pipe 2). The automatic ARIMA chose ARIMA(1,0,0) with a mean for Pipe 1 and ARIMA(0,0,0)(0,0,1)[24] with a mean for Pipe 2. Two of the four filled hours of Pipe 1 (1 November at 05:00 and 09:00) fall in its 48-hour holdout; they are not scored, because they are filled values and not observations.
# the candidate models from the simplest to the most complex
simplicity_order <- c("mean", "arima_000", "ets", "arima_100", "arima_001", "arima_auto",
"drift", "naive", "snaive")
pipe_candidates <- pipe_accuracy |>
filter(lb_pvalue >= 0.05) |> # keep models that pass the residual test
group_by(pipe) |>
mutate(percent_above_best = 100 * (RMSE / min(RMSE) - 1)) |>
ungroup() |>
filter(percent_above_best <= 1) # within 1% of the lowest holdout RMSE
pipe_candidates |>
arrange(pipe, RMSE) |>
select(pipe, model = .model, RMSE, percent_above_best, ljung_box_p = lb_pvalue) |>
knitr::kable(digits = 3)
| pipe | model | RMSE | percent_above_best | ljung_box_p |
|---|---|---|---|---|
| pipe1 | arima_100 | 3.947 | 0.000 | 0.454 |
| pipe1 | arima_auto | 3.947 | 0.000 | 0.454 |
| pipe1 | arima_001 | 3.948 | 0.001 | 0.447 |
| pipe1 | arima_000 | 3.951 | 0.086 | 0.592 |
| pipe1 | mean | 3.951 | 0.086 | 0.592 |
| pipe1 | ets | 3.951 | 0.088 | 0.592 |
| pipe2 | arima_auto | 16.638 | 0.000 | 0.235 |
| pipe2 | ets | 16.733 | 0.569 | 0.079 |
| pipe2 | arima_100 | 16.733 | 0.569 | 0.090 |
| pipe2 | arima_000 | 16.733 | 0.569 | 0.079 |
| pipe2 | mean | 16.733 | 0.569 | 0.079 |
| pipe2 | arima_001 | 16.733 | 0.570 | 0.090 |
pipe_selected <- pipe_candidates |>
group_by(pipe) |>
slice_min(match(.model, simplicity_order), n = 1, with_ties = FALSE) |> # the simplest of them
ungroup()
pipe_selected |> select(pipe, model = .model, RMSE, MAE, ljung_box_p = lb_pvalue) |> knitr::kable(digits = 3)
| pipe | model | RMSE | MAE | ljung_box_p |
|---|---|---|---|---|
| pipe1 | mean | 3.951 | 2.999 | 0.592 |
| pipe2 | mean | 16.733 | 13.750 | 0.079 |
for (k in pipe_selected$pipe) {
m <- pipe_selected |> filter(pipe == k) |> pull(.model)
print(pipe_fits[[k]] |> select(all_of(m)) |> gg_tsresiduals() + labs(title = paste(k, "-", m)))
}
The first selection table lists the models that pass the residual
test and are within 1% of the lowest RMSE; the naive, seasonal naive and
drift models fail the residual test in the accuracy table above and are
not in it. The lowest RMSE belongs to ARIMA(1,0,0) for Pipe 1 (3.947)
and to the automatic ARIMA for Pipe 2 (16.638), but the plain mean is
less than 1% above them (3.951 and 16.733), so it is the simplest model
in the range and is selected for both pipes (second selection table).
The ARIMA models’ advantage is within the 1% tolerance, so the simpler
mean is preferred. In the residual plots the autocorrelations stay
inside the dashed lines except for a few lags that are just outside,
which is about what chance gives, and the histograms are close to
bell-shaped. The Ljung-Box p-value of the mean model for Pipe 2 (0.079)
is above the 5% limit but not by much. For a white-noise series the best
forecast is the level of the series, and the plain mean estimates that
level; the NAIVE() forecast, which repeats the last value,
is no better here, and clearly worse for Pipe 1 (accuracy table above),
because it carries the noise of one hour into every later hour.
The selected model is refitted on all the hourly data of each pipe and forecasts the 168 hours that follow the last hour of the series. The table gives the first, middle and last hour of each forecast, and the graph shows the last seven days of history with the forecast and its 95% interval.
pipe_forecast <- map(names(pipes_ts), \(k) {
m <- pipe_selected |> filter(pipe == k) |> pull(.model)
fit_pipe(pipes_ts[[k]], which = m) |> forecast(h = 168) |>
hilo(level = 95) |>
as_tibble() |>
transmute(pipe = k, hour, model = m, forecast_flow = .mean, lower95 = `95%`$lower, upper95 = `95%`$upper)
}) |> list_rbind()
# first, middle and last hour of each week-ahead forecast
pipe_forecast |> group_by(pipe) |> slice(c(1, 84, 168)) |> ungroup() |> knitr::kable(digits = 1)
| pipe | hour | model | forecast_flow | lower95 | upper95 |
|---|---|---|---|---|---|
| pipe1 | 2015-11-02 00:00:00 | mean | 19.9 | 11.6 | 28.2 |
| pipe1 | 2015-11-05 11:00:00 | mean | 19.9 | 11.6 | 28.2 |
| pipe1 | 2015-11-08 23:00:00 | mean | 19.9 | 11.6 | 28.2 |
| pipe2 | 2015-12-03 17:00:00 | mean | 39.6 | 8.1 | 71.0 |
| pipe2 | 2015-12-07 04:00:00 | mean | 39.6 | 8.1 | 71.0 |
| pipe2 | 2015-12-10 16:00:00 | mean | 39.6 | 8.1 | 71.0 |
recent_hours <- hourly_both |> group_by(pipe) |> filter(hour > max(hour) - days(7)) |> ungroup()
ggplot() +
geom_line(data = recent_hours, aes(hour, flow), colour = "gray40") +
geom_ribbon(data = pipe_forecast, aes(hour, ymin = lower95, ymax = upper95), fill = "steelblue", alpha = 0.25) +
geom_line(data = pipe_forecast, aes(hour, forecast_flow), colour = "steelblue") +
facet_wrap(vars(pipe), scales = "free", ncol = 1) +
labs(title = "One-week forecast for each pipe (last 7 days of history)", x = "Hour", y = "Water flow")
For both pipes the forecast is a flat line at the average of the
series (19.9 for Pipe 1 and 39.6 for Pipe 2, the averages in the first
table of section C4), with a 95% interval of about 11.6 to 28.2 for Pipe
1 and about 8.1 to 71.0 for Pipe 2. The interval is about 1.96 standard
deviations of the hourly values either side of the average (the standard
deviations are in the first table of section C4); for Pipe 1 it is
narrower than the spread of the raw readings in C2 because each hourly
value is an average of several readings. This is what white noise
implies: the forecast says where the level is, and nothing about which
hour will be high or low. The forecasts of both pipes are in the Excel
file, sheets Water_Pipe1_Week and
Water_Pipe2_Week.
Limitations. The forecast assumes that the level stays where it was during the history, about ten days for Pipe 1 and about six weeks for Pipe 2; a change in demand or in the system would not be seen. Pipe 1 has only 240 hours of history, about ten days. The models are chosen and reported on one holdout each, which Kuhn and Johnson (section 4.3) describe as a weak basis, so the holdout errors are probably a little optimistic; the models that pass the residual test are within 1% of each other, so the choice among them rests on the preference for the simplest model and not on a measured difference.
Used: time plots and the ACF to look at the hourly series; the KPSS test and the number of differences for stationarity; the Ljung-Box test and the ACF to judge white noise; the strength of a trend and of a 24-hour pattern; the four benchmarks, ETS, and automatic and manual ARIMA models; holdout RMSE and MAE, with the simplest model within a 1% tolerance of the best chosen (Kuhn and Johnson, section 4.6); the mean of the readings within each hour; and the average of the neighboring hours to fill the four missing hours.
Not used: a Box-Cox transformation, because the spread of both series looks constant over time (section C4). A decomposition for forecasting, because the 24-hour pattern is weak. Rolling cross-validation, to keep the comparison simple. KNN or model-based interpolation for the four missing hours, because they are isolated and the series has no pattern to follow.
write_xlsx(list(
ATM_May2010 = may_forecast |>
transmute(DATE, ATM, forecast_cash_hundreds = forecast, forecast_cash_dollars = forecast_dollars,
lower95_hundreds = lower95, upper95_hundreds = upper95, model),
Power_2014 = power_forecast |> mutate(month = as.character(month)),
Water_Pipe1_Week = pipe_forecast |> filter(pipe == "pipe1") |>
transmute(hour = format(hour, "%Y-%m-%d %H:%M"), forecast_flow, lower95, upper95, model),
Water_Pipe2_Week = pipe_forecast |> filter(pipe == "pipe2") |>
transmute(hour = format(hour, "%Y-%m-%d %H:%M"), forecast_flow, lower95, upper95, model)
), "Project1_Forecasts.xlsx")
The forecasts are written to Project1_Forecasts.xlsx:
ATM_May2010 (daily, per ATM, in hundreds and in dollars),
Power_2014 (monthly, in KWH), and for the bonus
Water_Pipe1_Week and Water_Pipe2_Week (hourly,
168 hours each). The empty interval cells for ATM3 are intentional
(section A8).