Residual diagnostics and the Ljung-Box test

Today let's understand how to check whether a fitted model's residuals genuinely behave like noise, using a test called the Ljung-Box test.

Northfield Outdoor Supply sells hiking and camping gear, and its 60 months of revenue were fit with a random walk with drift, a simple model whose only rule is last month's revenue plus one steady step up. That model hands back a residual for every month it was trained on, the gap between what it predicted and what actually happened. Here are 59 of those residuals, one per month, plotted in the order they occurred.

The line crosses zero again and again, so there's no obvious drift up or down. But look closely and it is not pure static either: there are stretches where it climbs for a few months running, then stretches where it dips. That kind of leftover pattern is exactly what separates residuals that are genuinely noise from residuals that still have something left to explain.

What gg_tsresiduals() shows you at a glance

Every fitted time series model hands back a residual for each time point it was trained on, the gap between what actually happened and what the model predicted for that same point. If a model has truly captured whatever pattern lives in a series, those residuals should have nothing left to explain, just noise. If a pattern is still hiding inside them, the model missed something, and that is exactly what this lesson teaches you to catch.

augment() is the function that hands back a fitted model's residuals as three new columns next to the original data. .fitted is the model's prediction for that point. .resid is the plain miss, the real value minus .fitted, on the original scale.

.innov is the same miss, but measured on whatever scale the model actually fit, here the logged revenue scale, since fit_full was fit to log(revenue). Every check in this lesson runs on .innov.

Rebuild Northfield Outdoor Supply's 60 months of revenue, fit the same random walk with drift model, and hand the fitted model straight to gg_tsresiduals(), which draws three panels from .innov in one single call.

RInteractive R
# Rebuild Northfield's revenue, fit a random walk with drift, and draw its residual diagnostics library(tsibble) library(fable) library(fabletools) library(feasts) set.seed(2024) month <- yearmonth("2020 Jan") + 0:59 level <- 40000 + 480 * (0:59) season <- 1 + 0.18 * sin(2 * pi * (((0:59) %% 12) - 3) / 12) noise <- exp(rnorm(60, 0, 0.05)) revenue <- round(level * season * noise) sales <- tsibble(month = month, revenue = revenue, index = month) fit_full <- model(sales, drift = RW(log(revenue) ~ drift())) aug_full <- augment(fit_full) gg_tsresiduals(fit_full)

  

The top panel plots .innov against month, in the order the months occurred. The bottom-left panel is the ACF of .innov, one bar per lag. The bottom-right panel is a histogram of .innov's values. Together, those three panels are the fastest way to eyeball whether a model's residuals look like plain noise or still carry a pattern.