rguides

Time Series with tsibble and fable

If you have ever lost an afternoon to wrestling a ts() matrix back into a data frame, or to running the same forecast::auto.arima() call on a hundred stores in a for loop, the tidyverts stack is the time series answer. Four packages — tsibble, fabletools, fable, and feasts — replace the matrix-and-loop world with a data-frame-first pipeline that uses the same dplyr verbs you already know. This guide walks through that pipeline end to end, using a real multi-series dataset, and finishes with rolling-origin cross-validation in a single pipe.

TL;DR

  • A tsibble is a tibble with two declared fields: an index (time) and a key (series id). Both survive pipes, joins, and filters.
  • Use as_tsibble() to convert a tibble, and wrap monthly dates in yearmonth() (not Date) so the interval stays regular.
  • index_by() |> summarise() aggregates temporally; fill_gaps() inserts missing time points as NA rows.
  • model() returns a mable (one row per series, one column per model). Fit several models in one call to compare.
  • forecast(<mable>, h = "3 years") returns a fable with distributional point forecasts and prediction intervals.
  • stretch_tsibble() |> model() |> forecast() |> accuracy() is the canonical time series cross-validation pipe.
  • feasts is where you go for ACF, PACF, STL decomposition, and stationarity tests before and after fitting.

Why a tsibble

A tsibble is a tibble with two extra promises attached: a declared index (the time variable) and a declared key (the series identifier). That is the entire data structure contract. Everything downstream, from model() to forecast() to accuracy(), relies on those declarations to know which rows belong to which series and which column carries time.

With ts() you carry that information in a name string; with zoo and xts you carry it in row names. A tsibble puts it in the object’s metadata, so it survives pipes, filters, and joins without you having to reconstruct it. The header printed at the top of every tsibble is the contract in action: [1h] is the interval, Key: Sensor is the series identifier, and both stay attached to the object no matter what you do to it.

library(tsibble)
library(dplyr)

ped <- tsibbledata::pedestrian

The pedestrian dataset is one of the most useful examples in tsibbledata and ships with the package. It holds 18,000 hourly pedestrian counts from four Melbourne sensors across 2015 and 2016, giving you a real multi-key tsibble out of the box, complete with a daily cycle and a weekly cycle. Bind it to a name and let the print method summarise it.

The print method shows the structure at a glance. The [1h] interval means the rows are spaced one hour apart, and the Key: Sensor line tells you that the 18,000 rows are split across the four sensor columns. The truncation marks like Birrarung … are the print method fitting long character values into the console; the full sensor names are still recoverable with unique(ped$Sensor) when you need them.

ped

Calling print on the tsibble triggers the specialised print method, which renders the index interval, the active key, and the first few rows inline at the REPL. The header you see next: # A tsibble: 18,000 x 5 [1h] plus # Key: Sensor. That is the contract in action: one hour between rows, four sensors grouped together as the panel key, and 18,000 total observations across the two-year span.

# A tsibble: 18,000 x 5 [1h]
# Key:       Sensor
#   Sensor       Date_Time           Date        Time  Count
#   <chr>        <dttm>              <date>      <int> <int>
# 1 Birrarung … 2015-01-01 00:00:00  2015-01-01      0  1630
# 2 Birrarung … 2015-01-01 01:00:00  2015-01-01      1   826
# 3 Birrarung … 2015-01-01 02:00:00  2015-01-01      2   567

Installation

The four core packages each have a clear role, and they install independently from CRAN. The most useful extras for the workflow in this guide are tsibbledata for example datasets, plus the usual tidyverse companions that the rest of the code blocks assume are already loaded.

install.packages(c(
  "tsibble", "fable", "fabletools", "feasts", "tsibbledata",
  "lubridate", "dplyr", "ggplot2"
))

tsibbledata is optional, but its example datasets (aus_retail, pedestrian, PBS, olympic_running) save you from synthesising your own. lubridate, dplyr, and ggplot2 are the standard companions and almost always come along for the ride. If you plan to fit a lot of models across many series, install future and furrr as well; fabletools will pick up a future::plan() and parallelise model fitting without any code changes on your part.

How do you build a tsibble from a tibble?

Most real data lands as a data frame first, so the workhorse function is as_tsibble(). Pass the data, the index column, and (for multiple series) the key. The example below constructs a small monthly sales table for two stores and turns it into a tsibble in one pipeline.

library(tsibble)
library(lubridate)

monthly_sales <- tibble::tribble(
  ~month,    ~store, ~units,
  "2024-01", "A",    1024,
  "2024-02", "A",    1117,
  "2024-03", "A",    1198,
  "2024-01", "B",     812,
  "2024-02", "B",     890,
  "2024-03", "B",     933
) |>
  dplyr::mutate(month = yearmonth(month)) |>
  as_tsibble(index = month, key = store)

The pipeline is straightforward: turn the month string into a yearmonth, declare that column as the index, and declare store as the key. Once both are set, as_tsibble() returns a regular monthly tsibble with two series, one per store, and the print method confirms the structure on the next line. Note that the order of the mutate() and as_tsibble() calls matters: as_tsibble() needs the index column to be a tsibble-aware time class, not a plain string or a Date, so the wrap has to happen first.

After running the pipeline, you have a regular monthly tsibble with the 1M interval and the store key, and the next two print blocks show the input alongside the result so you can compare the original data shape with the tsibble metadata at a glance.

monthly_sales

Printing the tsibble at the REPL renders the index interval, the key, and the first rows. The # A tsibble: 6 x 3 [1M] header is the contract: six rows, three columns, monthly interval, with store declared as the panel key. The <mth> type on the month column confirms the yearmonth class that keeps the interval regular, and the Key: store line tells fable downstream that each row belongs to one of two series.

# A tsibble: 6 x 3 [1M]
# Key:       store
#   month   store  units
#   <mth>   <chr>  <dbl>
# 1 2024 Jan A       1024
# 2 2024 Feb A       1117
# 3 2024 Mar A       1198
# 4 2024 Jan B        812
# 5 2024 Feb B        890
# 6 2024 Mar B        933

Two things are worth flagging here. First, the index is wrapped in yearmonth() rather than left as a string or a Date. A Date column for monthly data is irregular (28 to 31 days), which silently breaks the regular [1M] interval and confuses every downstream model that assumes even spacing. yearmonth() is one of tsibble’s purpose-built index classes; the others are yearquarter(), yearweek(), and yearday(). See the yearmonth() reference for parsing details.

Second, key = store declares the series identifier, and the Key: line in the print method confirms it. A key is not the same as a dplyr::group_by(): the key is part of the tsibble’s identity and persists through model() and forecast(), while a group_by() is a transient operation that can be added and removed freely.

To inspect what you have:

interval(monthly_sales)   # [1] 1M
is_regular(monthly_sales) # [1] TRUE
key_vars(monthly_sales)   # [1] "store"

interval() returns a tsibble interval object (the [1] 1M is the print form of a yearmonth interval). is_regular() is the boolean check that downstream models use to decide whether seasonal terms are even meaningful, and key_vars() returns the key column names as a character vector so you can use them programmatically.

What verbs does tsibble add on top of dplyr?

tsibble ships with two verbs that do not exist in plain dplyr. index_by() performs grouped temporal aggregation, and fill_gaps() inserts explicit rows for missing time points so models do not silently see a shorter series than you think. There is also count_gaps() for diagnosing holes, which pairs naturally with fill_gaps().

retail <- tsibbledata::aus_retail

# Total monthly turnover across all of Australia
retail_total <- retail |>
  index_by(Year_Month = yearmonth(Month)) |>
  summarise(Turnover = sum(Turnover))

index_by() returns another tsibble; it groups by the new time index you give it (monthly, here). From there, summarise() works exactly as in dplyr, and the result is a one-key tsibble with a single series. The aus_retail dataset has a complex key (State and Industry), so this aggregation rolls it up to a single national turnover series. That simplification makes it a good test case for model() and forecast() before you try anything with the full panel.

To check whether any series has holes:

retail |> count_gaps()

count_gaps() walks every key combination and reports a row for each contiguous run of missing observations, with the start time and the length of the gap. The function does not modify the data; it just diagnoses, which makes it the natural first call whenever a model behaves oddly. If you do not see a row for a particular key, that key has no gaps and you can move on.

# A tibble: 0 x 4
# … with 4 variables: State, Industry, .from, .n

Zero rows means the aus_retail series is complete. The columns listed in the … with 4 variables: footer are still the ones that would appear if gaps were present, and they tell you which key variables are involved and where the holes start and how long they run. If you do find gaps, fill_gaps() inserts the missing time points as NA rows, and you can pass a function (often fill = NA or tidyr::fill()) to plug them.

retail |>
  fill_gaps() |>
  count_gaps()

fill_gaps() is the right way to deal with regular = FALSE warnings. Forcing a tsibble to be irregular usually means hiding a problem rather than solving one, and most fable models assume regular spacing under the hood. After fill_gaps(), a follow-up call to count_gaps() should return zero rows, confirming that the series is now whole and ready for modelling.

How do you fit forecasting models with fabletools and fable?

model() is the entry point. You give it a tsibble and one or more model specifications, and it returns a mable (model table): one row per series in the key, one column per model. Fitting multiple specifications in a single call is the standard comparison workflow; the cells hold fitted model objects, and you read the results side by side to pick a winner with accuracy().

library(fable)

# Fit two models in a single call
fit <- retail_total |>
  model(
    ets  = ETS(Turnover),
    arima = ARIMA(Turnover)
  )

Each named argument inside model() becomes a column in the returned mable, holding one fitted model per series in the key. With one series in retail_total the mable has a single row, but the same call over a multi-key tsibble would produce one row per series with the same two model columns repeated. You read the columns side by side and pick the winner with accuracy().

After the call finishes, the mable has one row per series and one column per model, with each cell holding the fitted model object. For this one-series tsibble the mable is a single row with an ets cell and an arima cell, and you can pull either one out with select(fit, <name>) and inspect it with report().

fit

Printing the mable shows the abbreviated model type in each cell, with the fitted object summarised behind a short label. The header # A mable: 1 x 2 confirms one row and two model columns. The cell types are ETS(M,Ad,A) (multiplicative errors with additive damped trend and additive seasonality) and ARIMA(0,1,1)(1,1,1)[12] (a seasonal ARIMA), and both labels are just the canonical print representations of the fitted objects underneath.

# A mable: 1 x 2
#              ets          arima
#          <ETS(M,Ad,A)> <ARIMA(0,1,1)(1,1,1)[12]>
# 1        <ETS model>   <ARIMA model>

Each cell is a fitted model object. You can pull a single one out with select(fit, arima) and inspect it with report(), tidy(), or glance():

fit |> select(arima) |> report()

Running report() on a single column strips the model back down to a familiar regression-style summary. The AIC, AICc, and BIC values are on the original scale of the response, and the coefficient table includes the seasonal AR and MA terms because fable’s ARIMA() automatically fits a seasonal component when one is present in the data. The sigma^2 line is the estimated innovation variance, and the log likelihood is what fable uses internally for the information criteria above it.

# Series: Turnover
# Model: ARIMA(0,1,1)(1,1,1)[12]
# Coefficients:
#           ma1     sar1     sma1
#       -0.6829  -0.6039  -0.6005
# sigma^2 estimated as 2.5e+09:  log likelihood=-1483
# AIC=2974.6   AICc=2974.8   BIC=2986.5

The fable convention for ETS() is worth a mention. Components are error, trend, and season, and the common short forms are ETS(y) for automatic selection, ETS(y ~ trend("A") + season("M")) to force additive damped trend with multiplicative seasonality, and so on. fable uses an innovations state space parameterisation, so AICc values are not directly comparable to those from the older forecast::ets() function. The printed ETS(M,Ad,A) in the mable header means multiplicative errors, additive damped trend, and additive seasonality, which is the canonical choice for monthly retail data with trend and stable seasonal swings.

Other models worth knowing about: TSLM() for time series linear regression with trend and season terms (it wraps base lm() under the hood, so the linear regression in R tutorial is a useful companion read), SNAIVE() and NAIVE() as cheap benchmarks, NNETAR() for a single-hidden-layer neural net, and VAR() for multivariate series. The full catalogue lives at the fable reference.

How do you produce forecasts from a mable?

forecast() is the generic that turns a mable into a fable: a tsibble of point forecasts and prediction intervals. Pass the horizon as a number, or as a string with a period matching the data. The example below asks for three years of monthly forecasts, which fable expands into 36 monthly periods because it understands period arithmetic for tsibble indices.

fc <- fit |> forecast(h = "3 years")

Each row of the resulting fable represents one step ahead in the forecast horizon, and the rows are ordered first by model and then by time, so the ets and arima forecasts interleave when stacked together. The horizon string "3 years" is expanded by fable into 36 monthly periods, one for each future month, which is what gives the 72-row fable shown below (36 periods × 2 models). fable uses period arithmetic on the tsibble’s interval class, so the string form only works when the period unit matches the data; for daily data you would need h = 30 or h = "6 weeks" instead.

Print the result to see the distributional forecasts laid out row by row. The Turnover column is a dist object, the .mean column is the point forecast, and .lower / .upper are the default 80% and 95% prediction interval bounds. Pass level = c(80, 95) to forecast() to control the interval widths. The Key: .model header tells you that the 72 rows are split across the two model names.

fc

The print method renders the first few rows of the fable with the distribution column shown as a ~N(mean, var) shorthand, one row per forecast horizon step per model. The 72 rows in the truncated output below represent 36 future months times 2 models, in that order. The .mean, .lower, and .upper columns are the point forecast and the default 80% and 95% interval bounds extracted from the dist object, and they line up row by row with the same monthly timestamps you would see in the original retail_total series.

# A fable: 72 x 5 [1M]
# Key:     .model
#   .model Year_Month Turnover             .mean .lower .upper
#   <chr>  <mth>      <dist>               <dbl>  <dbl>  <dbl>
# 1 ets    2018 Dec   ~N(8945, 1.5e+05)    8945.  8390.  9499.
# 2 ets    2019 Jan   ~N(7520, 1.6e+05)    7520.  6890.  8150.
# 3 arima  2018 Dec   ~N(8945, 1.4e+05)    8945.  8390.  9499.

Turnover is now a distributional forecast object (fable’s dist class), and .mean is the point forecast. The strings "3 years" and "12 months" work because fable understands period arithmetic for tsibble indices. For quarterly data use "4 quarters" or "1 year"; for daily data use h = 30 (a number) or "6 weeks". Mixing up the unit is the most common cause of unexpected horizon lengths in rolling-origin forecasts.

To plot it:

fc |> autoplot(retail_total)

autoplot() returns a ggplot, so you can layer themes and scales on it like any other (the ggplot2 basics tutorial covers the underlying grammar). Pass the training data as the second argument and the plot method draws the historical series as a faded line behind the forecast fan. If you want a single model on its own panel, pull it out with fc |> filter(.model == "ets") before plotting, or use autoplot() on the mable itself to compare fitted values across models.

What does time series cross-validation look like?

The tidyverts version of a rolling-origin evaluation is stretch_tsibble(). It takes a tsibble and produces a nested version where each .id slice is a training set that grows by .step rows at a time. The recommended modern pattern is a four-step pipe that fits, forecasts, and scores every window in one go.

library(dplyr)
library(fable)

cv <- retail_total |>
  stretch_tsibble(.init = 36, .step = 1) |>
  model(ets = ETS(Turnover)) |>
  forecast(h = 1) |>
  accuracy(retail_total)

The four-step pipe is the canonical time series cross-validation workflow in the tidyverts ecosystem, and the same shape works for any combination of model specs. Because we fit only one model here, the accuracy() tibble has a single row, and the metrics shown are on the original turnover scale so RMSE and MAE are in the same units as the data. The .type column tells you the row is a test-set evaluation; fable also reports training-set metrics in a separate row when you pass only the mable. If you have many series, wrap the whole pipe in group_by_key() to parallelise across keys.

# A tibble: 1 x 10
#   .model .type      ME   RMSE   MAE   MPE  MAPE  MASE  RMSSE      ACF1
#   <chr>  <chr>  <dbl>  <dbl> <dbl> <dbl> <dbl> <dbl>  <dbl>     <dbl>
# 1 ets    Test    31.1   569.  471. -0.16  5.59 0.621  0.632 -0.000756

forecast(h = 1) produces a one-step-ahead prediction for each window, and accuracy() compares them against the held-out truth in retail_total. The recommended modern pattern is exactly this four-step pipe: stretch_tsibble() |> model() |> forecast() |> accuracy(). To compare models, fit several in the same model() call before the forecast() step, and accuracy() will return one row per model so you can rank by RMSE or MAPE. For multi-key tsibbles, accuracy() returns one row per model per key, which you can then arrange() to get a clean ranking.

What does feasts add on top of fable?

feasts is the analysis companion: it gives you the diagnostics you need before and after fitting. The most common pre-modelling check is gg_tsdisplay(), which plots the series, its ACF, and its PACF on a single ggplot so you can spot trend, seasonality, and the order of differencing you will need for ARIMA in one glance.

library(feasts)

retail_total |>
  gg_tsdisplay(Turnover, plot_type = "partial")

That one-liner draws the time series, its ACF, and its PACF on a single ggplot, which is the standard pre-modelling visual check. The functions you will reach for next are STL() for seasonal-trend decomposition, features() for lumping many summary statistics into a tibble, and unitroot_pp() and unitroot_kpss() for stationarity tests. feasts is worth a guide of its own; for now, treat it as the place you go to look at a series before deciding what to fit.

Common gotchas

A few pitfalls that show up often enough to be worth flagging up front:

  • as_tsibble() prints “Using x as index variable” when you do not pass index =. It picks the first time-like column. Always pass index = explicitly to avoid surprises, especially after a join or mutate() that may have reordered the columns.
  • For monthly data, wrap the date in yearmonth(), never use a Date column. Date monthly indices are irregular (28 to 31 days) and break the regular-spacing assumptions in ETS() and SNAIVE(). The same logic applies to weekly and quarterly data, which is why yearweek() and yearquarter() exist.
  • A tsibble key is not the same as dplyr::group_by(). The key is part of the tsibble’s identity and persists through model() and forecast(). Use it for series identity; use group_by() for transient operations on a tsibble, and call ungroup() before you pass the result to a model.
  • Errors inside model() become <NULL model> cells by default (the .safely = TRUE argument). If accuracy() returns NA for a row, that is usually the cause. Set .safely = FALSE in development to fail loudly and see the actual error message.
  • fable’s ETS() uses an innovations state space form, so its AICc is not directly comparable to forecast::ets() AICc. If you need to compare models, compare them within fable. The same caveat applies to the seasonal ETS variants and the SNAIVE() benchmark.
  • h = "12 months" only works for monthly tsibbles. For quarterly, use "4 quarters"; for daily, use a number or "6 weeks". Mixing units is the most common rolling-origin error, and it usually shows up as a forecast that is ten times longer or shorter than you expected.

Frequently asked questions

What is the difference between a tsibble and a ts object? A ts object is a numeric matrix with a time index stored in its name string. A tsibble is a tibble with the time index and series keys declared as part of the object metadata. The tibble form composes with dplyr, joins, and ggplot2 in ways the matrix form cannot.

Do I need lubridate to use tsibble? Not strictly — as.Date() columns work for daily data. But for monthly, quarterly, or weekly indices you should use yearmonth(), yearquarter(), or yearweek() because plain Date columns are irregular at those frequencies and break the regular-spacing contract.

Can I fit multiple model specifications in one model() call? Yes. Pass each spec as a named argument and you get a mable with one column per model. The standard pattern is model(fit, ets = ETS(y), arima = ARIMA(y), tslm = TSLM(y ~ trend() + season())) and then accuracy() to rank them by RMSE or MAPE.

How is fable different from the forecast package? fable is the tidyverts replacement for forecast. The big differences are: fable returns model tables (mable / fable) instead of single-model objects, models are specified with formulas rather than separate arguments, and the pipeline is pipe-native. AICc values are not comparable across the two packages because fable uses an innovations state space form for ETS.

Can I use fable with my own panel data? Yes, as long as you can identify one time column and one or more key columns. The as_tsibble(data, index = time_col, key = c(group1, group2)) call is usually enough. If the panel is irregular, run count_gaps() and fill_gaps() first.

Conclusion

The tidyverts approach trades the ts()-style “everything in a matrix” model for a data-frame-first pipeline that composes with the rest of the tidyverse. Once a tibble becomes a tsibble, the same dplyr verbs you use elsewhere carry you through aggregation, modelling, and forecast evaluation on any time series you can put into a data frame. The next step is to feed your own time series into the same as_tsibble() |> model() |> forecast() |> accuracy() chain and let the diagnostics in feasts tell you which model to trust. From there, the most common moves are adding fill_gaps() upstream, switching the key to a panel of related series, and reaching for stretch_tsibble() once you want a real out-of-sample number to report. Most time series projects in R end up circling back to that one chain, and the four-package pipeline it lives in is the most direct path from raw data to a defensible time series forecast.

See also

  • Forecasting in R with fable — sister guide focused on the forecast() and accuracy() workflow with a different angle on model selection.
  • summarise() reference — the dplyr verb that powers every index_by() |> summarise() pipeline in this article.
  • Working with dates in R — lubridate is the natural preprocessing companion for turning raw date columns into tsibble indices.
  • Linear regression in RTSLM() wraps base lm() under the hood, so the regression fundamentals carry over directly.
  • ggplot2 basics — every autoplot() call returns a ggplot, and the standard layer grammar applies.