EDA for time series
Today let's understand how to look at a time series properly, before you ever fit a model to it, using one real dataset.
Here is 37 years of real data: the monthly turnover of cafes, restaurants and takeaway food outlets in Victoria, Australia, from April 1982 to December 2018, in $ million. That's 441 months in a row, published by the Australian Bureau of Statistics, plotted once, in full.
Look at that shape for a second. It climbs for nearly four decades, but it is not a smooth climb: small bumps ride on top of it every single year. Everything in this lesson is about reading that shape properly, one piece at a time, before fitting anything to it.
The plots-first workflow and autoplot()
Before you fit anything to a time series, look at it properly first. That is exploratory data analysis, EDA for short, applied to a time series: a short, repeatable checklist you run before any model touches the data.
For a time series, that checklist covers six things, always in this order:
- Shape - what does the series look like, overall?
- Season-and-year summaries - how does it move within a year, and across years?
- Outliers - which points do not fit the pattern the rest of the series follows?
- Calendar effects - do those "outliers" actually repeat every year, at the same point in the calendar?
- Structural breaks - did the whole series shift level at some point?
- Missing runs - are there gaps in the data itself?
You already saw the first item on the cover: the raw shape of the series. To make that same plot in R, use autoplot(), the plotting function built for a tsibble that skips ggplot2's usual setup. Build the tsibble first.
aus_retail is a large keyed tsibble bundled with the tsibbledata package: one series per Australian state and industry. Filtering down to Victoria's cafes, restaurants and takeaway food services leaves one series, 441 months, from April 1982 to December 2018. [1M] in the header says its index moves in fixed 1-month steps.
Now plot it with autoplot().
autoplot() reads the tsibble's index directly, so you only name the column to plot, Turnover, and it draws the line correctly ordered by Month without you writing a single ggplot() call. The plot is the same one from the cover: a level that rises for nearly four decades, an unmistakable trend, with a small wave riding on top of it every year.
Summaries by season and year
Shape tells you the series rises. It does not tell you which months are typically strong and which are typically weak, or whether that pattern holds steady from one year to the next. For that, summarise the series by season and by year.
Pivot the tsibble so each row is a calendar month and each column is a year, for the last three years in the data.
Read down any one column and turnover climbs from a January dip to a December peak, the same shape every year. Read across any one row and the number grows a little every year, the trend you already saw on the plot. December 2018, at $1,066.2 million, is the single highest month in this slice; February 2016, at $732.4 million, is the lowest.
Three years hints at a pattern. Thirty-seven years confirms it. Average each calendar month over the full 1982-2018 history and rank the months from strongest to weakest.
December averages $461.4 million across all 37 years, the highest of any month by a wide margin. February averages $363.5 million, the lowest. That is the seasonal pattern this series carries every single year: a December peak, driven by Christmas trading, and a February trough, a shorter month with fewer trading days. Here is the three-year slice again, as a report-ready table.
Spotting outliers with the STL remainder
Season-and-year summaries tell you the typical pattern. To find the months that broke it, you need to strip that pattern away and look at what is left over. STL, short for Seasonal-Trend decomposition using Loess, does exactly that: it splits a series into a trend (the slow-moving overall level), a season component (the repeating yearly wave) and a remainder (whatever is left once both are subtracted out).
Fit STL on cafe, with season(window = "periodic") telling it to use one fixed seasonal shape for the whole 37 years, rather than letting that shape drift year to year.
Add up trend, season_year and remainder for any row and you get back Turnover exactly. trend is a smooth, slow-moving line. season_year is the same fixed December-up, February-down wave every year. remainder is what is left over: how far off trend + season_year sits from the real number, for that one month.
Most months have a small remainder, a few $million either way. A month whose remainder is unusually large, relative to the rest, is a candidate outlier. "Unusually large" needs a threshold, and standard deviation gives you a simple one: flag any month whose remainder sits more than 2 standard deviations from zero.
The remainder's standard deviation across all 441 months is about 17. Twice that is 34, so any month whose remainder is more than 34 $million above or below zero gets flagged. That rule catches 29 of the 441 months, about 1 in 15. Here is the full series again, with those 29 months picked out.
Switch that widget to points and the 29 flagged months stand out clearly against the rest. But a flag is only a candidate. The next question is whether each one is a genuine surprise, or something the calendar explains perfectly well.
Quick check: the table and the flagging rule
Calendar effects: when a big residual isn't an outlier
29 flagged months is a starting list, not a final answer. Before you call any of them a genuine surprise, check whether it repeats. A remainder that comes back at the same calendar position, year after year, is not one-off at all: it is the fixed seasonal shape season(window = "periodic") assumed failing to fit that particular month exactly. That is a calendar effect, not an outlier.
Count the 29 flagged months by which calendar month they fall on.
Two months dominate the list: December, 11 of the 29 flags, and February, 8 of them. Together that is 19 of the 29, two out of every three flagged months, all landing on the same two calendar positions. Look at each one closely, with its year and its sign.
Every one of the 8 flagged Februaries is negative, and every one of them falls in 2011 or later. February is a short month with fewer trading days than a 30 or 31-day month, so its total turnover runs low even when daily spending has not changed at all. As the whole series grew larger in later decades, that same February shortfall turned into a bigger dollar gap, big enough to clear the 34 threshold from 2011 on.
December tells a two-part story. The 4 flagged Decembers from 1984 to 1998 are all negative: the fixed seasonal shape from season(window = "periodic") was set higher than the smaller 1980s and 90s economy actually delivered. The 7 flagged Decembers from 2010 on are all positive: Christmas trading grew to outgrow that same fixed shape. Same calendar month, opposite sign, two different eras.
A flag that recurs at the same calendar position most years, in a direction the calendar itself explains, is a calendar effect. That leaves 10 of the original 29 flags, the ones scattered across September, January, May, June and August, as points still worth a closer look for a genuine, one-off surprise.
Structural breaks: a level shift the trend doesn't explain
Calendar effects repeat every year. A structural break is different: a point where the series shifts to a new level and stays there, something the smooth trend component was not built to catch in one sharp move.
Compute the mean turnover for each of the 37 years, then the percentage change from one year to the next.
lag(mean_turnover) shifts the column down by one row, so subtracting it from the current row's value and dividing gives each year's change from the year before. Most years land somewhere between roughly 1% and 20%. One year sits well clear of all the others: 1999, up 26.6% on 1998, the single biggest year-over-year jump anywhere in this 37-year series.
A jump that size, that does not repeat and does not fade back, is a structural break: the series moved to a new level around 1999 and stayed there. At this stage in the EDA-for-time-series workflow, the job is to flag that break for whoever builds the model next, not to explain it. Whatever caused it, a chain expanding, a new competitor closing, a change in how the ABS measured the category, is a separate investigation. What matters here is that any model fit across 1999 has to account for that level shift directly, or the jump ends up folded into ordinary trend and season terms that were never built to explain it.
Missing runs: finding gaps before you model
The last item on the checklist has nothing to do with unusual values. It is about rows that are not there at all. Here is a second, much smaller tsibble to show it clearly: a shop's daily sales for the first 17 days of January 2024, with 3 days missing because the shop closed for a stocktake.
14 rows, not 17. Nothing in that print shouts "gap" on its own; you would have to notice the date column jumps from January 7 straight to January 11. scan_gaps() and count_gaps() do that check for you.
scan_gaps() lists every missing date, one row at a time: January 8, 9 and 10. count_gaps() groups a run of consecutive missing dates into a single summary row instead: .from and .to mark where the run starts and ends, and .n counts how many dates fall inside it, 3 in this case, the exact 3-day stocktake closure.
Neither function changes shop. If you want the missing dates turned into real rows instead of just reported, fill_gaps(shop) does that: it inserts a row for each of the 3 missing dates, with sales set to NA, taking shop from 14 rows to 17. Report first with scan_gaps() and count_gaps(), repair only if the next step in your workflow actually needs a complete series.
Quick check: outlier, calendar effect, or structural break?
A colleague looks at three flagged points in their own retail series and asks you to sort them. The first is a month that comes in far below its neighbours every single year, always at the same point in the calendar. The second is one year where the yearly mean jumps far more than in any other year, and it never happens again. The third is a flagged month with no repeating pattern before or after it, just one unexplained spike.
Your turn: find the gap and flag the point
Here is a second small series to practice both rules on: a helpdesk's daily ticket count for the 42 days from March 1 to April 11, 2024, with one day missing.
Show answer
# Find the missing date, then flag the oversized day
count_gaps(tickets)
#> # A tibble: 1 x 3
#> .from .to .n
#> <date> <date> <int>
#> 1 2024-03-19 2024-03-19 1
filter(tickets_dcmp, abs(remainder) > 2 * remainder_sd)
#> # A tibble: 1 x 3
#> date count remainder
#> <date> <dbl> <dbl>
#> 1 2024-03-30 95 38.2Before your turn starts, here is the remainder column already computed for you, the same way you built it in the outlier step: fill the one missing day with the average of its two neighbours, so STL has a complete series to decompose, then run STL exactly as before.
That's a remainder sd of about 9.05. Now it's your turn: find the missing date in tickets with count_gaps(), and flag the oversized day in tickets_dcmp with the same 2 times sd rule you used earlier, abs(remainder) > 2 * remainder_sd.
March 19 is the missing day, the same one count_gaps() reported earlier. March 30 is the only day whose remainder clears 18.1: a count of 95 tickets against a typical day nearby, well outside anything the rest of the series produced.
References
Before you go, here is where the ideas in this lesson come from.
- Forecasting: Principles and Practice, 3rd edition - Hyndman, R.J. and Athanasopoulos, G. The chapters on time series graphics and STL decomposition behind this lesson's checklist.
- STL: A Seasonal-Trend Decomposition Procedure Based on Loess - Cleveland, R.B., Cleveland, W.S., McRae, J.E. and Terpenning, I. (1990), Journal of Official Statistics 6(1), 3-73. The original STL paper.
- tsibble package reference - has_gaps(), scan_gaps(), count_gaps() and fill_gaps() documentation.
- Retail Trade, Australia - Australian Bureau of Statistics. The source series behind tsibbledata::aus_retail.
Putting the EDA-for-time-series workflow together
Run back through the six-item checklist against what this one series actually showed you. Shape: a 37-year climb with a wave riding on top of it every year. Season-and-year summaries: December is the strongest month every year, averaging $461.4 million, and February the weakest, averaging $363.5 million. Outliers: an STL remainder with a standard deviation of about 17 flagged 29 of the 441 months. Calendar effects: 19 of those 29 flags, 8 Februaries and 11 Decembers, turned out to be the fixed seasonal shape missing a short month or an outgrown Christmas peak, not real surprises. Structural breaks: 1999 stood out as a 26.6% jump in the yearly mean, a level shift to flag for whoever models this series next, not to explain away. Missing runs: scan_gaps() and count_gaps() found a clean 3-day gap in the shop series, without needing to repair anything.
That order matters. Run the checks in this sequence and each one narrows what the next has to explain: season-and-year summaries account for the regular wave, the outlier rule finds what is left over, calendar effects clear out the repeating half of those flags, structural breaks catch the one-off level shifts, and missing runs catch what was never recorded in the first place. Skip straight to modelling without this pass and you risk fitting a model to a calendar quirk, a level shift, or a gap it was never built to handle.
Next, you will look at the seasonal shape itself more closely, with plots built specifically to compare one year's pattern against another.