Forecast Evaluation Exercises in R: 50 Practice Problems
Fifty problems on judging whether a forecast is any good: raw errors, MAE and RMSE, MAPE and MASE, honest train/test splits, the accuracy() function, benchmark comparisons, residual diagnostics, rolling-origin cross-validation and prediction-interval coverage. Every solution is hidden until you click reveal.
Work top to bottom. Sections 5 to 7 reuse objects built earlier, so run each section's setup block before its exercises.
Section 1. Forecast errors from first principles (7 problems)
Every accuracy metric is a summary of one vector: the forecast errors. Build that vector by hand first, then watch each metric squeeze it into a single number. These exercises use one small demand table.
Exercise 1.1: Compute raw forecast errors from actuals and predictions
Task: A demand planner has eight weeks of actual units sold alongside the forecast issued a week earlier, in the weekly data frame. Compute the forecast error for each week as actual minus forecast and save the result to ex_1_1.
Expected result:
#> [1] 5 -5 -2 4 5 -7 9 -3
Difficulty: Beginner
The error is defined as what happened minus what you said would happen, computed one row at a time.
Subtract the two columns directly: R does the arithmetic element by element.
Click to reveal solution
Explanation: Forecast error is conventionally actual minus forecast, so a positive value means you under-forecast and a negative value means you over-forecast. Keeping that sign convention matters: reverse it and every bias diagnostic later in this hub flips direction. R subtracts the two columns element by element, so no loop is needed.
Exercise 1.2: Measure forecast bias with the mean error
Task: The planner wants to know whether the forecast leans high or low on average across the eight weeks. Compute the mean of the errors you built in ex_1_1 and save the result to ex_1_2.
Expected result:
#> [1] 0.75
Difficulty: Beginner
Positive and negative errors should cancel here: that cancellation is exactly what makes this number a bias measure, not an accuracy measure.
Apply mean() to the error vector.
Click to reveal solution
Explanation: Mean error (ME) measures bias, not accuracy. A value near zero says over- and under-forecasts roughly balance out, which is what you want, but a forecast that misses by +50 and -50 alternately also scores zero. That is why ME is always read next to MAE or RMSE, never alone.
Exercise 1.3: Compute mean absolute error by hand
Task: Compute the mean absolute error of the weekly forecast by averaging the absolute values of the errors stored in ex_1_1, label it MAE and save the named result to ex_1_3 so you can compare it with the bias number.
Expected result:
#> MAE
#> 5
Difficulty: Beginner
Stripping the sign before averaging stops overshoots and undershoots from cancelling each other out.
Wrap the error vector in abs() before passing it to mean(), then name the value with c(MAE = ...).
Click to reveal solution
Explanation: MAE reports the typical miss in the units of the series, here five units of demand per week. Compare it with the ME of 0.75 from the previous exercise: the forecast is nearly unbiased yet still off by five units a week, which is the standard reason to report both numbers together. Naming the value with c(MAE = ...) keeps the output self-labelling.
Exercise 1.4: Compute root mean squared error by hand
Task: Compute the root mean squared error for the same eight weekly errors in ex_1_1 by squaring, averaging, then taking the square root, and save the rounded result to ex_1_4 for comparison against MAE.
Expected result:
#> [1] 5.4083
Difficulty: Intermediate
Square first so that large misses count for more than proportionally, then undo the squaring at the very end to get back to the original units.
Compose sqrt(mean(e^2)) where e is the error vector.
Click to reveal solution
Explanation: RMSE is always greater than or equal to MAE, and the gap widens as the errors become more uneven. Here RMSE is 5.41 against an MAE of 5, a small gap because no single week blows out. Optimising RMSE targets the conditional mean of the series, while optimising MAE targets the conditional median.
Exercise 1.5: Convert absolute errors into percentage errors
Task: The planner reports to a stakeholder who thinks in percentages rather than units. Convert each of the eight errors in ex_1_1 into a percentage of that week's actual demand, round to two decimals and save to ex_1_5.
Expected result:
#> [1] 4.17 -3.70 -1.56 2.82 3.33 -5.07 5.59 -1.94
Difficulty: Beginner
A percentage error rescales each error by the size of the thing being forecast, so the denominator changes from row to row.
Compute 100 * error / actual, then round with round(x, 2).
Click to reveal solution
Explanation: Dividing by the actual makes errors comparable across weeks of different volume, which is why percentage errors travel well between products or regions. The catch appears whenever an actual is near zero: the denominator collapses and the percentage explodes. Exercise 3.2 puts a number on how badly that distorts the average.
Exercise 1.6: Write a reusable error-summary function
Task: Rather than retyping three formulas every time, write a function error_summary(actual, forecast) that returns a named vector of ME, MAE and RMSE, call it on the weekly columns, round to four decimals and save to ex_1_6.
Expected result:
#> ME MAE RMSE
#> 0.7500 5.0000 5.4083
Difficulty: Intermediate
Compute the error vector once inside the function body, then build all three summaries from it.
Return c(ME = ..., MAE = ..., RMSE = ...) so the names come back attached to the values.
Click to reveal solution
Explanation: Naming the elements inside c() gives you a self-documenting result you can index by name later, as in ex_1_6[["RMSE"]]. Computing e once avoids recomputing the subtraction three times, and the same skeleton extends cleanly to MAPE or MASE. The forecast package ships accuracy() for exactly this job, covered in Section 4.
Exercise 1.7: Show how RMSE punishes a single large miss
Task: Compare two eight-period error vectors with identical MAE: e_spread misses by three every period, while e_spike is perfect except for one miss of 24. Build a data frame of MAE and RMSE for both and save it to ex_1_7.
Expected result:
#> series MAE RMSE
#> 1 spread 3 3.000
#> 2 spike 3 8.485
Difficulty: Intermediate
Both vectors are engineered to have the same average absolute miss, so any difference in the second metric comes from how the errors are distributed.
Build the frame with data.frame(series = ..., MAE = ..., RMSE = ...) and compute each metric with mean(abs(x)) and sqrt(mean(x^2)).
Click to reveal solution
Explanation: Identical MAE, RMSE nearly triple. Squaring gives the lone 24-unit miss enormous weight, so RMSE is the metric to choose when one catastrophic error costs far more than several small ones, such as a stockout. When all misses cost the same per unit, MAE is the honest summary and RMSE will mislead you.
Section 2. Scale-dependent metrics on real series (7 problems)
MAE and RMSE speak in the units of the series, which makes them easy to explain and impossible to compare across series. These exercises use the Nile flow record, the WWWusage internet-traffic series and the AirPassengers split from the setup block.
Exercise 2.1: Score a naive forecast of Nile flow on held-out years
Task: A hydrologist holds out the last ten years of the Nile annual flow record. Forecast those ten years with the naive method fitted on nile_train, compute the mean absolute error against nile_test and save the rounded value to ex_2_1.
Expected result:
#> [1] 128
Difficulty: Beginner
The naive method carries the last observed value forward, so every one of the ten forecasts is the same number.
Call naive(nile_train, h = 10) and compare its $mean component against nile_test.
Click to reveal solution
Explanation: The $mean element of a forecast object holds the point forecasts as a ts aligned to the test period, so subtracting nile_test matches observations to forecasts by date automatically. An MAE of 128 is meaningless on its own; it only becomes interpretable next to the series level of roughly 900, or next to a rival method.
Exercise 2.2: Compare the mean method against naive on internet traffic
Task: Using the WWWusage split, forecast the final 20 periods with both the mean method and the naive method, compute the RMSE of each against www_test and save both values in one named vector called ex_2_2.
Expected result:
#> mean_method naive_method
#> 69.442 82.817
Difficulty: Intermediate
One method predicts the historical average forever, the other predicts the most recent value forever: they will disagree sharply on a trending series.
Use meanf() and naive() with h = 20, and score each with the rmse() helper from the setup block.
Click to reveal solution
Explanation: The mean method wins here, which is a useful surprise: WWWusage rises then falls back, so the long-run average lands closer than the last observed value. Never assume naive is the strongest baseline. Running both costs one extra line and occasionally saves you from shipping a model that loses to an average.
Exercise 2.3: Test whether adding drift improves the Nile forecast
Task: A drift forecast extends the average historical slope forward instead of holding flat. Compute the test RMSE of both the naive and the drift method on nile_test over the ten-year horizon and save the pair to ex_2_3.
Expected result:
#> naive drift
#> 152.954 155.374
Difficulty: Intermediate
Drift is the random walk with a slope estimated from the first and last training observations, so it only helps when the series genuinely trends.
Use rwf(nile_train, h = 10, drift = TRUE) alongside naive(nile_train, h = 10).
Click to reveal solution
Explanation: Drift is slightly worse, so the extra parameter bought nothing. The Nile record has a level shift around 1900 rather than a steady trend, and drift fitted over the whole history extrapolates a slope that is not really there. Added flexibility must earn its keep on held-out data, not on the training fit.
Exercise 2.4: Score a seasonal naive forecast of air passengers
Task: Using ap_train and ap_test from the setup block, forecast 24 months ahead with the seasonal naive method, then compute both MAE and RMSE against the test window and save them as a named vector ex_2_4.
Expected result:
#> MAE RMSE
#> 71.250 76.995
Difficulty: Intermediate
Seasonal naive repeats the value from the same month one year earlier, which is the right baseline for a monthly series with a strong annual cycle.
Call snaive(ap_train, h = 24) and score its $mean with mean(abs(...)) and the rmse() helper.
Click to reveal solution
Explanation: RMSE exceeds MAE by about 8 percent, a modest gap saying no single month blows out. Seasonal naive is the benchmark every serious model on this series must beat, because it captures the annual cycle for free. Note that it ignores the upward trend entirely, which is why the errors grow with horizon in Exercise 2.6.
Exercise 2.5: Show that MAE cannot be compared across two series
Task: Compute the naive-method MAE on the Nile test window and on the WWWusage test window, put both in one named vector ex_2_5, and notice that the smaller number does not mean the better forecast.
Expected result:
#> nile_MAE www_MAE
#> 128.0 76.2
Difficulty: Beginner
One series is measured in hundreds of millions of cubic metres, the other in tens of users, so their errors live on different scales.
Compute mean(abs(actual - forecast)) once per series and combine the two with a named c().
Click to reveal solution
Explanation: The WWWusage MAE is smaller in absolute terms, yet relative to a series that sits around 200 it is a far worse miss than 128 on a series that sits around 900. Scale-dependent metrics compare models on one series only. Section 3 introduces MAPE and MASE, which are built to cross that boundary.
Exercise 2.6: Break the seasonal naive error down by forecast horizon
Task: Forecast accuracy usually decays as the horizon lengthens. Build a data frame with one row per horizon from 1 to 24 holding the absolute error of the seasonal naive forecast at that horizon, save it to ex_2_6 and print the first six rows.
Expected result:
#> h abs_error
#> 1 1 20
#> 2 2 24
#> 3 3 44
#> 4 4 48
#> 5 5 57
#> 6 6 37
Difficulty: Advanced
The forecast object already stores the 24 point forecasts in the order they occur, so horizon is just the position in that vector.
Build data.frame(h = 1:24, abs_error = round(as.numeric(abs(ap_test - fc_sn$mean)), 1)).
Click to reveal solution
Explanation: as.numeric() strips the time-series attributes so the result is a plain column rather than a ts that data.frame() would mangle. A single average MAE hides this profile entirely: a model that is excellent one month out and hopeless at twelve can post the same headline number as a mediocre but stable one.
Exercise 2.7: Find the horizons where the forecast fails worst
Task: From the per-horizon table in ex_2_6, identify the five horizons with the largest absolute error by sorting in descending order and keeping the top rows, then save that ranked slice to ex_2_7.
Expected result:
#> h abs_error
#> 1 19 131
#> 2 16 113
#> 3 17 109
#> 4 21 104
#> 5 22 102
Difficulty: Intermediate
You want the rows ordered from worst to best, then cut off after the first handful.
Chain arrange(desc(abs_error)) into head(5).
Click to reveal solution
Explanation: Every one of the worst five horizons sits in the second forecast year, which is the fingerprint of a method that ignores trend: seasonal naive repeats 1958 twice while traffic keeps climbing. Diagnosing failure by horizon tells you what to fix, whereas a single MAE only tells you that something is wrong.
Section 3. Percentage and scaled errors (7 problems)
Percentage errors let you compare across series, and they misbehave whenever the actuals approach zero. Scaled errors fix that by dividing by an in-sample benchmark instead of by the actual. These exercises build both by hand before trusting any package.
Exercise 3.1: Compute MAPE for the seasonal naive forecast by hand
Task: Compute the mean absolute percentage error of the seasonal naive forecast fc_sn against ap_test, using each month's own actual as the denominator, then round to three decimals and save the result to ex_3_1.
Expected result:
#> [1] 15.523
Difficulty: Intermediate
Convert each error to a percentage of that period's actual first, take absolute values, and only then average.
Compute mean(100 * abs((actual - forecast) / actual)).
Click to reveal solution
Explanation: The forecast is off by about 15.5 percent on average, a statement any stakeholder understands without knowing the series scale. Take the absolute value of the ratio, not of the numerator alone, or negative actuals would silently flip signs. Exercise 4.2 confirms this figure against the MAPE column of accuracy().
Exercise 3.2: Show how MAPE explodes when actuals approach zero
Task: A near-zero actual makes the percentage-error denominator collapse. Using the small data frame, add a pct_error column holding the absolute percentage error per row, save the result to ex_3_2 and print the overall MAPE.
Expected result:
#> actual forecast pct_error
#> 1 0.5 2 300.0
#> 2 2.0 3 50.0
#> 3 10.0 11 10.0
#> 4 25.0 24 4.0
#> 5 40.0 41 2.5
#> MAPE: 73.3
Difficulty: Advanced
Look at the first row before computing anything: a miss of 1.5 units against an actual of 0.5 is a huge percentage even though the absolute miss is tiny.
Use mutate(pct_error = round(100 * abs(actual - forecast) / actual, 1)), then mean() that column.
Click to reveal solution
Explanation: Four of the five rows are forecast to within 10 percent, yet the reported MAPE is 73.3 because one row with an actual of 0.5 contributes 300. This is why MAPE is dangerous for intermittent demand, new products or any series that can dip near zero. Use MASE or a scaled error there instead.
Exercise 3.3: Compute symmetric MAPE on the same awkward data
Task: Symmetric MAPE divides by the average of the actual and the forecast rather than by the actual alone. Compute sMAPE on the small data frame, round to two decimals and save the result to ex_3_3 for comparison with the 73.3 you just saw.
Expected result:
#> [1] 35.21
Difficulty: Advanced
Making the denominator depend on both numbers stops a tiny actual from dominating, because the forecast props the denominator up.
Compute mean(200 * abs(actual - forecast) / (abs(actual) + abs(forecast))).
Click to reveal solution
Explanation: sMAPE halves the damage, 35.2 against 73.3, because the forecast of 2 keeps the first denominator away from zero. The 200 in the numerator is not a typo: dividing by the sum rather than the mean of the two values needs a factor of two to stay on a percentage scale. sMAPE is still unstable when both values approach zero, and it is not truly symmetric, so treat it as a patch rather than a cure.
Exercise 3.4: Compute MASE for the Nile forecast with a naive scaling term
Task: MASE divides the test MAE by the in-sample mean absolute change of a naive forecast. Compute that scaling term on nile_train, divide the naive test MAE by it and save the rounded value to ex_3_4.
Expected result:
#> [1] 0.968
Difficulty: Advanced
The denominator is how badly a one-step naive forecast would have done inside the training period, which is the yardstick everything is measured against.
Compute mean(abs(diff(nile_train))) for the scale, then divide the test MAE by it.
Click to reveal solution
Explanation: A MASE just under 1 means the ten-year-ahead naive forecast is marginally better than a one-step naive forecast was in training, which is close to a coin flip. The diff() call gives period-to-period changes, and averaging their absolute values gives the natural difficulty scale of the series. MASE has no zero-denominator problem unless the series never changes.
Exercise 3.5: Compute seasonal MASE for the air passenger forecast
Task: For seasonal data the MASE denominator uses the seasonal naive method instead. Compute the mean absolute twelve-month difference of ap_train, divide the seasonal naive test MAE by it, and save the rounded value to ex_3_5.
Expected result:
#> [1] 2.494
Difficulty: Advanced
The right yardstick for monthly seasonal data is how far the series moves from one year to the same month next year.
Pass lag = 12 to diff() when building the scaling term.
Click to reveal solution
Explanation: A MASE of 2.49 says the 24-month-ahead forecast is roughly two and a half times worse than an in-sample one-step seasonal naive forecast, which is expected given the long horizon. Getting the lag wrong is the classic MASE bug: using lag = 1 on seasonal data produces a much smaller denominator and a flatteringly inflated score.
Exercise 3.6: Compare two models on a common MASE scale
Task: Fit an ETS model to ap_train, forecast 24 months, then build a two-row data frame comparing the MASE of the seasonal naive and ETS forecasts against ap_test using the shared scale_ap denominator, saved as ex_3_6.
Expected result:
#> model MASE
#> 1 snaive 2.494
#> 2 ets 2.212
Difficulty: Intermediate
Both models must be divided by the same scaling term, or the comparison is meaningless.
Fit with ets(ap_train), forecast with forecast(fit, h = 24), then divide each test MAE by scale_ap.
Click to reveal solution
Explanation: ETS beats the benchmark by roughly 11 percent on MASE. Because the denominator is a property of the training data rather than of any model, MASE values are comparable across models on this series and across series in a portfolio, which is why forecasting competitions favour it. ets() here selects a multiplicative error, damped trend and multiplicative seasonality specification automatically.
Exercise 3.7: Screen a series for values that make MAPE undefined
Task: Before reporting MAPE on a new series, audit it for zeros and negatives. Given the audit vector, count how many values are exactly zero, how many are negative, and flag whether MAPE is safe, saving all three to ex_3_7.
Expected result:
#> n_zero n_negative mape_safe
#> 2 1 0
Difficulty: Intermediate
A zero actual makes the percentage error infinite and a negative actual makes it change sign, so both must be caught before the metric is computed.
Use sum(x == 0), sum(x < 0) and as.numeric(all(x > 0)) inside a named c().
Click to reveal solution
Explanation: Two zeros and one negative mean MAPE is not usable, and the flag comes back 0. R will happily return Inf for the zero rows and a misleading finite number for the negative one rather than erroring, so this check has to be explicit. When it fails, switch to MASE or to a scale-dependent metric reported alongside the series mean.
Section 4. Train/test splits and the accuracy() function (8 problems)
Everything so far was computed by hand so the formulas are no longer a black box. The accuracy() function in the forecast package returns all of them at once, in two rows: one for training fit, one for held-out data. The gap between those rows is the single most useful number in this hub.
Exercise 4.1: Split a monthly series into training and test windows
Task: An atmospheric scientist wants to hold out the last four years of the co2 record. Split the series at the end of 1993 using window(), then save the lengths of the two pieces as a named vector ex_4_1.
Expected result:
#> train test
#> 420 48
Difficulty: Beginner
A time-series split must respect chronology: the test set is always the tail, never a random sample.
Use window(co2, end = c(1993, 12)) and window(co2, start = c(1994, 1)), then length() each.
Click to reveal solution
Explanation: window() subsets by calendar time rather than by position, so the c(year, month) pairs are readable and the resulting objects keep their frequency and start date. Random train/test splits, standard in cross-sectional machine learning, leak future information into training here and produce accuracy figures that collapse in production.
Exercise 4.2: Read the full accuracy table for a seasonal naive forecast
Task: Call accuracy() on the seasonal naive forecast fc_sn with ap_test supplied as the held-out data, round the resulting matrix to three decimals and save it to ex_4_2 so you can read the training and test rows side by side.
Expected result:
#> ME RMSE MAE MPE MAPE MASE ACF1 Theil's U
#> Training set 28.259 32.506 28.574 11.258 11.410 1.000 0.766 NA
#> Test set 71.250 76.995 71.250 15.523 15.523 2.494 0.728 1.52
Difficulty: Intermediate
Passing the actual future values as the second argument is what unlocks the second row of the table.
Call accuracy(fc_sn, ap_test) and wrap it in round(x, 3).
Click to reveal solution
Explanation: The test MAPE of 15.523 and MASE of 2.494 match what you computed by hand in Exercises 3.1 and 3.5, confirming the formulas. Training MASE is exactly 1.000 because the seasonal naive method is its own scaling benchmark. Call accuracy() with only the forecast object and you get the training row alone, which is not evidence of anything.
Exercise 4.3: Pull a single metric out of the accuracy matrix
Task: Reporting pipelines usually need one number, not a matrix. Extract just the test-set RMSE from the accuracy table of fc_sn against ap_test by indexing with row and column names, then save the rounded value to ex_4_3.
Expected result:
#> [1] 76.995
Difficulty: Beginner
The object returned by the accuracy call is a matrix whose rows and columns both carry names you can index with.
Index it as acc["Test set", "RMSE"].
Click to reveal solution
Explanation: Name-based indexing survives changes in column order, whereas acc[2, 2] breaks silently if a future package version inserts a metric. This one-liner is the building block for the comparison tables in Exercise 4.7 and Section 5, where the same extraction runs once per model.
Exercise 4.4: Quantify the gap between training and test error
Task: Compare how the seasonal naive method scores on data it was built from against data it has never seen. Build a named vector holding the training RMSE, the test RMSE and their ratio, and save it to ex_4_4.
Expected result:
#> train_RMSE test_RMSE ratio
#> 32.506 76.995 2.369
Difficulty: Intermediate
Store the accuracy matrix once in a variable, then pull both rows from it rather than recomputing.
Divide the test RMSE by the training RMSE to get the ratio, and combine all three with a named c().
Click to reveal solution
Explanation: Test error is 2.4 times training error. For a method with zero fitted parameters that gap is not overfitting: it is the cost of forecasting up to 24 months ahead when the training row measures one-step-ahead fit. Comparing a multi-step test row against a one-step training row is the most common misreading of this table.
Exercise 4.5: Score an ETS model on the same held-out window
Task: Using the ETS fit built in Exercise 3.6, produce the full accuracy table of fc_ets against ap_test, round it to three decimals and save it to ex_4_5 so it can be compared row by row with the seasonal naive table.
Expected result:
#> ME RMSE MAE MPE MAPE MASE ACF1 Theil's U
#> Training set 1.208 8.898 6.654 0.394 2.777 0.233 0.119 NA
#> Test set 63.211 72.548 63.213 13.303 13.303 2.212 0.746 1.357
Difficulty: Intermediate
The call is identical to the one you used for the benchmark: only the forecast object changes.
Pass fc_ets and ap_test to accuracy() and round to three decimals.
Click to reveal solution
Explanation: Training RMSE drops from 32.5 to 8.9 while test RMSE only improves from 77.0 to 72.5. A fitted model always looks dramatically better in-sample because it optimised that fit; the honest improvement is the 6 percent on the test row. Theil's U below 1 would mean beating the naive benchmark, and at 1.357 this forecast does not.
Exercise 4.6: Score an automatically selected ARIMA model
Task: Fit an ARIMA model to ap_train with automatic order selection, forecast 24 months ahead, then save the rounded accuracy table against ap_test to ex_4_6 and note which specification was chosen.
Expected result:
#> ME RMSE MAE MPE MAPE MASE ACF1 Theil's U
#> Training set -0.016 9.568 7.120 -0.033 2.902 0.249 0.008 NA
#> Test set 68.577 74.252 68.577 14.928 14.928 2.400 0.718 1.465
Difficulty: Advanced
Order selection can be delegated: one function searches over p, d, q and their seasonal counterparts using an information criterion.
Use auto.arima(ap_train), then forecast(fit, h = 24) before calling accuracy().
Click to reveal solution
Explanation: The search lands on ARIMA(1,1,0)(0,1,0)[12], which differences once at lag 1 and once at lag 12. Training ACF1 of 0.008 means the residuals are essentially uncorrelated, a good sign that Section 6 tests formally. Note the model selection used AICc on training data only, so the test row remains a fair evaluation.
Exercise 4.7: Build a three-model comparison table from the test rows
Task: Stakeholders want one table, not three matrices. Write a helper that pulls the test-set RMSE, MAE and MAPE for a forecast object, apply it to the seasonal naive, ETS and ARIMA forecasts, and bind the rows into ex_4_7.
Expected result:
#> model RMSE MAE MAPE
#> 1 snaive 76.99 71.25 15.52
#> 2 ets 72.55 63.21 13.30
#> 3 arima 74.25 68.58 14.93
Difficulty: Advanced
Write the extraction once as a function taking the forecast object and a label, then call it three times.
Grab the whole test row with accuracy(fc, ap_test)["Test set", ] and stack results with bind_rows().
Click to reveal solution
Explanation: Extracting the test row returns a named numeric vector, so a[["RMSE"]] gives a bare number rather than a one-element vector with a stray name. All three metrics agree on the ranking here, which is reassuring; when they disagree, the metric that matches the cost of being wrong in your business is the one that decides.
Exercise 4.8: Verify that the split leaks no future data into training
Task: Before trusting any test score, prove the two windows do not overlap. Build a named vector holding the last training time, the first test time and a 1 or 0 flag for whether training ends strictly before testing begins, saved to ex_4_8.
Expected result:
#> train_end test_start no_overlap
#> 1958.917 1959.000 1.000
Difficulty: Intermediate
Every ts object carries its own timestamps, so the check is a comparison of two dates rather than of two row counts.
Use max(time(ap_train)) and min(time(ap_test)), and coerce the comparison with as.numeric().
Click to reveal solution
Explanation: December 1958 prints as 1958.917 because time() expresses months as fractions of a year, eleven twelfths being 0.917. The flag of 1 confirms a clean boundary. An off-by-one window() call that repeats December in both sets inflates test scores in a way no metric will ever reveal on its own, so this assertion belongs in any automated pipeline.
Section 5. Benchmarks, baselines, and model choice (7 problems)
An accuracy number means nothing until it beats something. This section builds a benchmark suite, ranks models against it, and shows why an information criterion computed on training data is not a substitute for a test score.
Exercise 5.1: Build a four-method benchmark table
Task: Forecast ap_test with the naive, seasonal naive, mean and drift methods, then build a data frame holding the test RMSE and MAE of each so you know the bar any real model must clear, saved as ex_5_1.
Expected result:
#> method RMSE MAE
#> 1 naive 137.33 115.25
#> 2 snaive 76.99 71.25
#> 3 mean 219.44 206.34
#> 4 drift 115.70 91.62
Difficulty: Advanced
Put the four forecast objects in a list so you can score them all with one pass instead of four copy-pasted blocks.
Build with list(naive = naive(...), snaive = ..., mean = meanf(...), drift = rwf(..., drift = TRUE)) and apply the metrics using sapply().
Click to reveal solution
Explanation: The spread is enormous: the mean method is nearly three times worse than seasonal naive, because averaging a strongly trending series produces a forecast below every future value. Wrapping sapply() in as.numeric() drops the inherited names so the data frame gets clean row numbers instead of duplicated labels.
Exercise 5.2: Select the winning benchmark programmatically
Task: Rather than eyeballing the table, pick the method with the lowest RMSE from ex_5_1 by locating the minimum position and using it to index the method column, saving the winning name to ex_5_2.
Expected result:
#> [1] "snaive"
Difficulty: Intermediate
You need the position of the smallest value, not the smallest value itself.
Use which.min() on the RMSE column and index the method column with the result.
Click to reveal solution
Explanation: which.min() returns the index of the first minimum, so ties resolve to the earliest row rather than erroring. Automating the choice matters when the same script runs across hundreds of series in a nightly job, where no human is available to read a table. Swap the column name to rank on MAE or MASE instead.
Exercise 5.3: Compare ETS against ARIMA on held-out RMSE
Task: Build a two-row data frame comparing the test RMSE of the ETS and ARIMA forecasts against ap_test, save it to ex_5_3, and treat the smaller number as the better model for this series and horizon.
Expected result:
#> model RMSE
#> 1 ets 72.55
#> 2 arima 74.25
Difficulty: Advanced
Both fitted objects and their forecasts already exist from the section setup, so only the scoring remains.
Score each with the rmse() helper against ap_test and assemble with data.frame().
Click to reveal solution
Explanation: ETS edges ARIMA by about 2 percent, which is well inside the noise you would expect from a single 24-month test window. A margin this thin is not a reason to declare a permanent winner; the rolling-origin evaluation in Section 7 averages over many origins and gives a far more stable verdict.
Exercise 5.4: Show that a lower AICc does not guarantee a lower test error
Task: Fit two explicitly specified ETS models to ap_train, one fully additive and one with multiplicative seasonality, then tabulate each model's AICc alongside its test RMSE and save the comparison to ex_5_4.
Expected result:
#> model AICc test_RMSE
#> 1 AAA 1247.9 91.22
#> 2 MAM 1117.2 72.55
Difficulty: Advanced
An information criterion scores in-sample likelihood with a penalty for parameters, so it never sees the test window at all.
Fit with ets(ap_train, model = "AAA") and ets(ap_train, model = "MAM"), read fit$aicc, and score forecasts separately.
Click to reveal solution
Explanation: Here AICc and test RMSE happen to agree, both favouring multiplicative seasonality, which is the right answer for a series whose seasonal swings grow with its level. They agree because the model families are comparable and the sample is honest; AICc is only valid within one model class fitted to identical data, so it can never be used to compare an ETS against an ARIMA on differenced data.
Exercise 5.5: Test whether averaging two forecasts beats both
Task: Combine the ETS and ARIMA point forecasts by simple averaging, then compute the test RMSE of the ETS forecast, the ARIMA forecast and the combination, saving all three in one named vector ex_5_5.
Expected result:
#> ets arima combo
#> 72.55 74.25 72.70
Difficulty: Advanced
A combination forecast is often more accurate than its members because their errors are partly independent and cancel.
Average the two $mean components with (a + b) / 2, then score all three with rmse().
Click to reveal solution
Explanation: The average lands between the two members rather than beating both, which happens when the models make highly correlated errors: both under-forecast the same climbing trend, so averaging cannot cancel anything. Combinations pay off when the members fail in different directions, and checking that empirically takes exactly the three lines above.
Exercise 5.6: Express model gain as a skill score against the benchmark
Task: Convert the ARIMA test RMSE into a percentage improvement over the seasonal naive benchmark, where a positive value means the model beat the benchmark, and save the rounded skill score to ex_5_6.
Expected result:
#> [1] 3.6
Difficulty: Intermediate
A skill score turns two error numbers into one relative statement, so the benchmark belongs in the denominator.
Compute 100 * (1 - model_rmse / benchmark_rmse).
Click to reveal solution
Explanation: A 3.6 percent gain is honest but small, and it is the number worth quoting to a stakeholder because it answers "compared to what". Skill scores are bounded above by 100 and unbounded below, so a model twice as bad as the benchmark scores minus 100. Always state which benchmark was used, since the score is meaningless without it.
Exercise 5.7: Rank every benchmark method into a report-ready table
Task: Sort the benchmark table ex_5_1 from best to worst RMSE and attach a rank column so the output can be pasted straight into a report, saving the ranked frame to ex_5_7.
Expected result:
#> method RMSE MAE rank
#> 1 snaive 76.99 71.25 1
#> 2 drift 115.70 91.62 2
#> 3 naive 137.33 115.25 3
#> 4 mean 219.44 206.34 4
Difficulty: Intermediate
Sort first, then number the rows in their new order, so the rank reflects the sorted position.
Chain arrange(RMSE) into mutate(rank = row_number()).
Click to reveal solution
Explanation: Order matters in the pipe: calling mutate() before arrange() would number the rows in their original order and produce a scrambled rank column. row_number() needs no arguments inside mutate() because it reads the current row position. Note that the MAE column happens to agree with the RMSE ranking here, which is not guaranteed.
Section 6. Residual diagnostics and forecast bias (7 problems)
Test-set metrics tell you how large the misses are. Residual diagnostics tell you whether the misses still contain exploitable structure, which is the difference between a model that is merely imperfect and one that is leaving information on the table.
Exercise 6.1: Check the ARIMA residuals for mean bias
Task: Extract the residuals from the fitted ARIMA model fit_ar and compute their mean and standard deviation, saving both to ex_6_1, since a well-behaved residual series should be centred on zero.
Expected result:
#> mean_residual sd_residual
#> -0.0161 9.6081
Difficulty: Beginner
Residuals are the one-step-ahead training errors, and their average is the in-sample bias.
Use residuals(fit_ar) and summarise with mean() and sd().
Click to reveal solution
Explanation: A mean of -0.016 against a standard deviation of 9.6 is effectively zero, so the fit carries no in-sample bias. A clearly nonzero mean would mean the forecasts can be improved for free by adding a constant. Note these are in-sample one-step residuals, not the multi-step test errors examined in Exercise 6.5, and the two often tell different stories.
Exercise 6.2: Test residual autocorrelation with a Ljung-Box test
Task: Run a Ljung-Box test on the ARIMA residuals over 24 lags, adjusting the degrees of freedom for the single estimated coefficient, and save the test object to ex_6_2 so the p-value can be read directly.
Expected result:
#> Box-Ljung test
#>
#> data: res
#> X-squared = 32.841, df = 23, p-value = 0.08388
Difficulty: Intermediate
The null hypothesis is that the residuals are independent, so a large p-value is the outcome you want here.
Call Box.test(res, lag = 24, type = "Ljung-Box", fitdf = 1).
Click to reveal solution
Explanation: With p above 0.05 you do not reject independence, so the residuals pass as white noise at the usual threshold, though 0.084 is close enough to be worth watching. The fitdf argument subtracts the number of estimated coefficients from the degrees of freedom: skip it and the test becomes too permissive because it credits the model with fitting for free.
Exercise 6.3: Read the first six residual autocorrelations
Task: Compute the autocorrelation of the ARIMA residuals at lags 1 through 6, dropping the trivial lag-zero value of 1, and save the rounded vector to ex_6_3 so individual problem lags become visible.
Expected result:
#> [1] 0.008 0.007 -0.174 -0.105 0.039 0.006
Difficulty: Intermediate
The correlation of a series with itself at lag zero is always 1 and carries no information, so it should be removed.
Use Acf(res, lag.max = 6, plot = FALSE)$acf, coerce with as.numeric() and drop the first element with [-1].
Click to reveal solution
Explanation: Lag 3 at -0.174 is the largest, close to the approximate significance bound of 2 divided by the square root of 120, which is about 0.18. The Ljung-Box test in the previous exercise pools all lags into one verdict, while this vector shows exactly where the structure sits, which is what you need to decide whether adding an MA term is worth trying.
Exercise 6.4: Summarise the spread of the residual distribution
Task: Prediction intervals assume a residual spread, so quantify it directly: compute the standard deviation of the ARIMA residuals plus their 2.5th and 97.5th percentiles, and save all three to ex_6_4.
Expected result:
#> sd_resid q025 q975
#> 9.608 -15.139 19.160
Difficulty: Intermediate
An empirical interval built from percentiles makes no normality assumption, unlike one built from the standard deviation alone.
Use quantile(res, 0.025, names = FALSE) and its 0.975 counterpart alongside sd().
Click to reveal solution
Explanation: A symmetric normal interval would put the two percentiles near plus and minus 18.8, so the empirical interval is noticeably lopsided: shorter on the downside, longer on the upside. That right skew is typical of a series with multiplicative growth and it warns that normal-theory prediction intervals will misstate risk, which Exercise 7.4 confirms with coverage.
Exercise 6.5: Detect systematic bias in the test-set errors
Task: Compute the mean test error of the ARIMA forecast against ap_test together with the share of test periods where the actual exceeded the forecast, saving both to ex_6_5 as a bias diagnostic on held-out data.
Expected result:
#> mean_error share_positive
#> 68.577 1.000
Difficulty: Intermediate
If a forecast is unbiased, roughly half its errors should fall on each side of zero.
Compute mean(e) and mean(e > 0) where e is the vector of test errors.
Click to reveal solution
Explanation: Every single one of the 24 test errors is positive, so the model under-forecasts the entire held-out period. Contrast this with the near-zero in-sample residual mean from Exercise 6.1: the model fits history without bias yet extrapolates a trend that is too shallow. Only out-of-sample errors expose that failure mode.
Exercise 6.6: Compare bias between the ARIMA and benchmark forecasts
Task: Build a two-row data frame holding the mean test error and the share of positive errors for both the ARIMA and the seasonal naive forecast, saving it to ex_6_6 to see whether the bias is model-specific or shared.
Expected result:
#> model mean_error share_positive
#> 1 arima 68.58 1
#> 2 snaive 71.25 1
Difficulty: Intermediate
If two structurally different models fail in the same direction, the cause is probably in the data rather than in either model.
Compute the error vectors separately, then assemble with data.frame() and round.
Click to reveal solution
Explanation: Both models under-forecast every single test month, so the problem is the data rather than the model class: air travel accelerated in 1959 and 1960 beyond anything in the training window. When every candidate is biased in the same direction, look for a regime change or a missing driver rather than tuning parameters.
Exercise 6.7: Compare in-sample residual spread against test RMSE
Task: Divide the test RMSE of the ARIMA forecast by the standard deviation of its in-sample residuals to see how far reality drifts from what the fit implied, saving the two inputs and the ratio to ex_6_7.
Expected result:
#> resid_sd test_rmse ratio
#> 9.608 74.252 7.728
Difficulty: Advanced
One number describes one-step-ahead errors inside the sample, the other describes errors up to 24 steps ahead outside it.
Divide the test RMSE by sd(res) and combine all three values with a named c().
Click to reveal solution
Explanation: Test error is nearly eight times the residual spread. Some of that gap is legitimate, because uncertainty compounds with horizon, but a factor of eight also reflects the systematic bias found in Exercise 6.5. Quoting residual standard deviation as though it were forecast accuracy is the most common way a forecast gets oversold internally.
Section 7. Rolling-origin evaluation and interval quality (7 problems)
A single train/test split gives you one number from one arbitrary cut point. Rolling-origin cross-validation re-forecasts from many origins and averages, which is far more stable. This section closes with prediction intervals, where being right on average is not enough.
Exercise 7.1: Run one-step rolling-origin cross-validation on the Nile series
Task: Use time-series cross-validation to generate one-step-ahead naive forecast errors across the whole Nile record, then compute the RMSE of those errors ignoring the leading missing values and save it to ex_7_1.
Expected result:
#> [1] 168.128
Difficulty: Advanced
The idea is to refit at every possible origin and keep only the error made one step past that origin.
Call tsCV(Nile, rwf, h = 1) and summarise the returned errors with sqrt(mean(e^2, na.rm = TRUE)).
Click to reveal solution
Explanation: tsCV() returns a series of errors aligned to the time the forecast was made, with NA at the start where too little history exists, so na.rm = TRUE is mandatory. Averaging over roughly a hundred origins instead of one makes this estimate far less sensitive to where you happened to cut the series.
Exercise 7.2: Measure how cross-validated error grows with horizon
Task: Run cross-validation on AirPassengers with the seasonal naive method for horizons 1 through 12, then compute the RMSE at each horizon from the returned error matrix and save the twelve values to ex_7_2.
Expected result:
#> h=1 h=2 h=3 h=4 h=5 h=6 h=7 h=8 h=9 h=10 h=11 h=12
#> 36.45 36.59 36.72 36.86 37.00 37.13 37.22 37.32 37.42 37.55 37.70 37.80
Difficulty: Advanced
With a horizon above 1 the result is a matrix, one column per horizon, so summarise down the columns rather than over everything.
Pass a wrapper function(x, h) snaive(x, h = h) to tsCV() and use sqrt(colMeans(e^2, na.rm = TRUE)).
Click to reveal solution
Explanation: Error climbs gently from 36.45 at one month to 37.80 at twelve, a much flatter profile than the single-split result in Exercise 2.6 because averaging over many origins washes out the peculiarities of 1959 and 1960. tsCV() needs a function of exactly (x, h), which is why the wrapper exists rather than passing snaive bare.
Exercise 7.3: Build an expanding-window evaluation loop by hand
Task: Reproduce what tsCV() automates: loop over origins from observation 60 to the second-to-last of the Nile series, forecast one step with the drift-free random walk, collect the errors and save the count and RMSE to ex_7_3.
Expected result:
#> n RMSE
#> 40.000 136.378
Difficulty: Advanced
At each origin the training window grows by one observation and the forecast is scored against the very next value.
Inside the loop build ts(Nile[1:i], start = 1871), forecast with rwf(tr, h = 1)$mean[1] and accumulate the errors.
Click to reveal solution
Explanation: Forty origins produce an RMSE of 136.4, lower than the 168.1 from Exercise 7.1 because starting at observation 60 skips the volatile pre-1900 era entirely. Which origins you include changes the answer, so state the evaluation window whenever you quote a cross-validated number. Growing a vector with c() is fine at this size but preallocate for long series.
Exercise 7.4: Measure the empirical coverage of a 95 percent interval
Task: Forecast 24 months from the ARIMA fit with a 95 percent prediction interval, then compute the share of test observations that actually fall inside the interval and save that coverage to ex_7_4.
Expected result:
#> [1] 0.542
Difficulty: Advanced
Coverage is a proportion of hits, so the comparison produces a logical vector whose mean is the answer.
Use forecast(fit_ar, h = 24, level = 95) and test actual >= fc$lower[, 1] & actual <= fc$upper[, 1].
Click to reveal solution
Explanation: An interval advertised at 95 percent captures only 54 percent of the actuals, so it is badly overconfident. $lower and $upper are matrices with one column per requested level, hence the [, 1]. Under-coverage this severe usually traces back to the same shallow trend that produced the one-sided bias in Exercise 6.5.
Exercise 7.5: Quantify interval sharpness as average width
Task: Coverage alone can be gamed by making intervals enormously wide, so measure sharpness too: compute the mean width of the 95 percent interval across the 24 test months and save the rounded value to ex_7_5.
Expected result:
#> [1] 140.11
Difficulty: Beginner
Width is simply the distance between the two interval bounds at each horizon.
Subtract the lower column from the upper column and take mean().
Click to reveal solution
Explanation: An average width of 140 passengers is wide relative to a series averaging around 450, and it still fails to cover. Coverage and sharpness must always be read together: a useless interval spanning zero to infinity has perfect coverage, and a razor-thin one has none. Good intervals hit their nominal rate with the smallest width that achieves it.
Exercise 7.6: Compare coverage and width at two confidence levels
Task: Produce a forecast carrying both 80 and 95 percent intervals, then build a data frame reporting the empirical coverage and mean width at each level, saving the result to ex_7_6 as an interval-quality summary.
Expected result:
#> level coverage mean_width
#> 1 80 0.042 91.6
#> 2 95 0.542 140.1
Difficulty: Intermediate
Requesting two levels returns two columns in each bound matrix, indexed in the order you asked for them.
Call forecast(fit_ar, h = 24, level = c(80, 95)) and compute coverage separately for column 1 and column 2.
Click to reveal solution
Explanation: The 80 percent interval catches 4 percent of observations and the 95 percent interval catches 54 percent, so both are far too narrow and the failure worsens as the level tightens. Because every test error is positive, the actuals sit above the upper bound almost everywhere, which is a bias problem showing up as a coverage problem.
Exercise 7.7: Assemble the final model-evaluation report
Task: Write a helper that computes RMSE, MAE, MAPE and MASE for a forecast object against ap_test, apply it to the seasonal naive, ETS and ARIMA forecasts, sort by RMSE and save the report table to ex_7_7.
Expected result:
#> model RMSE MAE MAPE MASE
#> 1 ets 72.55 63.21 13.30 2.212
#> 2 arima 74.25 68.58 14.93 2.400
#> 3 snaive 76.99 71.25 15.52 2.494
Difficulty: Advanced
Compute the error vector once inside the helper and derive all four metrics from it plus the shared scaling term.
Return a one-row data.frame() per model, stack with bind_rows() and finish with arrange(RMSE).
Click to reveal solution
Explanation: All four metrics rank the models identically, which is the comfortable case; when they disagree, decide in advance which one matches the cost of being wrong rather than picking the flattering one afterwards. Reusing scale_ap across every row is what makes the MASE column comparable. Note that even the winner is biased and under-covered, so this table alone is not a green light to ship.
What to do next
- ARIMA Exercises in R puts the models you were scoring here under the microscope: stationarity, order selection and diagnostics.
- Time Series Exercises in R covers the groundwork,
tsobjects, decomposition and seasonality, if the splits and windows felt shaky. - Forecast Accuracy in R is the companion tutorial explaining what each metric means before you practise it.
- dplyr Exercises in R drills the grouping and summarising verbs used to build every report table in Sections 4 to 7.
r-statistics.co · Verifiable credential · Public URL
This document certifies mastery of
Forecast Evaluation Mastery
Every certificate has a public verification URL that proves the holder passed the assessment. Anyone with the link can confirm the recipient and date.
65 learners have earned this certificate