--- title: "Multi-Step Time Series Forecasting with kerasnip" output: rmarkdown::html_vignette vignette: > %\VignetteIndexEntry{Multi-Step Time Series Forecasting with kerasnip} %\VignetteEngine{knitr::rmarkdown} %\VignetteEncoding{UTF-8} --- This vignette shows how to build a Keras recurrent model with `kerasnip` that forecasts several future steps of a time series at once, using the "single-shot" multi-step pattern from Keras/TensorFlow's own [time series forecasting tutorial](https://www.tensorflow.org/tutorials/structured_data/time_series), adapted to the `tidymodels` ecosystem. ## The Building Blocks Two ingredients turn flat, ordered tabular data into the shapes an RNN-based forecaster needs: * [`step_sequence()`](../reference/step_sequence.html) slides a window of `timesteps` past rows into a single list-column of `(timesteps, features)` matrices: the `(samples, timesteps, features)` shape `keras3::layer_lstm()` / `keras3::layer_gru()` expect as input. * [`step_lead()`](../reference/step_lead.html) builds the forecast target: one column per future step (`lead_1_`, `lead_2_`, ...). Unlike `recipes::step_lag()`, which only supports *past* shifts, `step_lead()` shifts *forward*, which is what a multi-step-ahead target needs. When both steps draw on the same raw column, **`step_lead()` must come first**: `step_sequence()` consumes (and drops) its source column once it has built the window, so `step_lead()` needs to see the raw column while it still exists. The resulting outcome columns (`lead_1_`, `lead_2_`, ...) are all numeric and map to a *single* `output` block sized `units = horizon`: one Keras output node predicting the whole vector of future values in one forward pass, sharing one loss. This is different from the multi-output models in `vignette("functional_api")`, where each outcome column becomes its own independently-configured head; here there is only one `output` block, so `kerasnip` packs the numeric outcome columns into a single `(samples, horizon)` matrix target instead of splitting them. ## Step 1: Load Libraries and Simulate a Series ``` r library(kerasnip) library(tidymodels) #> ── Attaching packages ───────────────── tidymodels 1.5.0 ── #> ✔ broom 1.0.13 ✔ recipes 1.3.3 #> ✔ dials 1.4.4 ✔ rsample 1.3.2 #> ✔ dplyr 1.2.1 ✔ tailor 0.1.0 #> ✔ ggplot2 4.0.3 ✔ tidyr 1.3.2 #> ✔ infer 1.1.0 ✔ tune 2.1.0 #> ✔ modeldata 1.5.1 ✔ workflows 1.3.0 #> ✔ parsnip 1.6.0 ✔ workflowsets 1.1.1 #> ✔ purrr 1.2.2 ✔ yardstick 1.4.0 #> ── Conflicts ──────────────────── tidymodels_conflicts() ── #> ✖ purrr::discard() masks scales::discard() #> ✖ dplyr::filter() masks stats::filter() #> ✖ parsnip::get_model_env() masks kerasnip::get_model_env() #> ✖ dplyr::lag() masks stats::lag() #> ✖ recipes::step() masks stats::step() library(keras3) #> #> Attaching package: 'keras3' #> The following object is masked from 'package:yardstick': #> #> get_weights #> The following object is masked from 'package:infer': #> #> generate # Silence the startup messages from remove_keras_spec options(kerasnip.show_removal_messages = FALSE) ``` A small synthetic series (a noisy sine wave riding on a slow trend) stands in for a real dataset, so the vignette has no external data dependency. ``` r set.seed(42) n <- 400 t <- seq_len(n) value <- sin(t / 10) + t / 400 + rnorm(n, sd = 0.05) series <- tibble::tibble(value = value) timesteps <- 24 # how much past history each window sees horizon <- 6 # how many future steps to forecast at once autoplot_data <- tibble::tibble(t = t, value = value) ggplot(autoplot_data, aes(t, value)) + geom_line() + labs(title = "Simulated series", x = "t", y = "value") ``` ![plot of chunk simulate-data](figure/simulate-data-1.png) Because forecasting is temporally ordered, the train/test split must respect time: `rsample::initial_time_split()` takes the first proportion of rows for training and the remainder for testing, rather than a random shuffle. ``` r split <- rsample::initial_time_split(series, prop = 0.8) train_data <- rsample::training(split) test_data <- rsample::testing(split) ``` ## Step 2: Build the Recipe `step_naomit()` defaults to `skip = TRUE`, so the rows it drops (those without a complete future window) are only dropped when *training*; at predict time, future values are legitimately unknown, and the row is kept. Both the window (predictor) and the lead columns (outcome) are *derived* from `value` by the recipe steps themselves, rather than existing as separate raw columns, so the recipe is built with `recipe(train_data)` (no formula) and each step assigns the role of the column(s) it creates (`step_lead()` defaults to `role = "outcome"`, `step_sequence()` to `role = "predictor"`). ``` r rec <- recipe(train_data) |> step_lead(value, lead = seq_len(horizon), prefix = "lead_") |> step_naomit(starts_with("lead_")) |> step_sequence(value, timesteps = timesteps, new_col = "window") ``` ## Step 3: Define Layer Blocks and the Model Specification The input block declares the `(timesteps, features)` shape, an LSTM layer summarizes the window, and a single `output` dense block emits all `horizon` forecasted values at once. ``` r input_block <- function(input_shape) { layer_input(shape = input_shape, name = "window_input") } lstm_block <- function(tensor, units = 32) { tensor |> layer_lstm(units = units) } # `units` needs a default to work around a doc-generator quirk when handling # args with no default; it is always overridden via `output_units` below. output_block <- function(tensor, units = 1) { tensor |> layer_dense(units = units) } model_name <- "multistep_lstm_spec" on.exit(remove_keras_spec(model_name), add = TRUE) create_keras_functional_spec( model_name = model_name, layer_blocks = list( window = input_block, lstm = inp_spec(lstm_block, "window"), output = inp_spec(output_block, "lstm") ), mode = "regression" ) ``` ## Step 4: Fit and Forecast `output_units` is set to `horizon` so the single output head predicts the full vector of future steps in one pass. ``` r spec <- multistep_lstm_spec( lstm_units = 32, output_units = horizon, fit_epochs = 30, fit_verbose = 0 ) |> set_engine("keras") wf <- workflow(rec, spec) fit_obj <- fit(wf, data = train_data) #> 10/10 - 1s - 72ms/step ``` `predict()` returns a nested `.pred` list-column: one row per input sample, each holding a small tibble of `.step` (1 to `horizon`) and `.pred` (the forecasted value at that step). This mirrors how the `censored` package nests multiple survival-probability values per row (`.pred` / `.eval_time` / `.pred_survival`), nesting over forecast step instead of evaluation time. ``` r preds <- predict(fit_obj, new_data = test_data) #> 2/2 - 1s - 308ms/step preds #> # A tibble: 57 × 1 #> .pred #> #> 1 #> 2 #> 3 #> 4 #> 5 #> 6 #> 7 #> 8 #> 9 #> 10 #> # ℹ 47 more rows preds$.pred[[1]] #> # A tibble: 6 × 2 #> .step .pred #> #> 1 1 0.916 #> 2 2 0.763 #> 3 3 0.735 #> 4 4 0.624 #> 5 5 0.462 #> 6 6 0.391 ``` ## Step 5: Visualize the Forecast Unnesting `.pred` turns the forecast horizon for one starting point into a plain tibble, easy to compare against the actual future values. ``` r one_forecast <- preds |> dplyr::slice(1) |> tidyr::unnest(.pred) actual_future <- test_data$value[seq_len(horizon) + timesteps - 1] comparison <- one_forecast |> dplyr::mutate(actual = actual_future) ggplot(comparison, aes(.step)) + geom_line(aes(y = .pred, color = "Forecast")) + geom_point(aes(y = .pred, color = "Forecast")) + geom_line(aes(y = actual, color = "Actual")) + geom_point(aes(y = actual, color = "Actual")) + labs( title = "6-step-ahead forecast vs. actual", x = "Step ahead", y = "value", color = NULL ) ``` ![plot of chunk plot-multistep](figure/plot-multistep-1.png) ## Step 6: Uncertainty Intervals `type = "conf_int"` and `type = "pred_int"` also work for this vector-valued output. Each forecast step gets its *own* last-layer Laplace posterior (its own prior precision and observation noise), sharing the same penultimate feature representation. This is the same independent-per-output treatment `kerasnip` already uses for separately named multi-output heads, generalized from "multiple Dense layers" to "multiple units of one Dense layer". It lets uncertainty differ (and typically grow) across the horizon instead of a single pooled width applied to every step alike. ``` r preds_ci <- predict(fit_obj, new_data = test_data, type = "conf_int") #> 2/2 - 0s - 32ms/step #> 2/2 - 0s - 45ms/step #> 2/2 - 0s - 77ms/step #> 2/2 - 0s - 47ms/step #> 2/2 - 0s - 30ms/step #> 2/2 - 0s - 48ms/step comparison_ci <- preds_ci |> dplyr::slice(1) |> tidyr::unnest(.pred) |> dplyr::mutate(actual = actual_future) ggplot(comparison_ci, aes(.step)) + geom_ribbon(aes(ymin = .pred_lower, ymax = .pred_upper), alpha = 0.2) + geom_line(aes(y = .pred, color = "Forecast")) + geom_point(aes(y = .pred, color = "Forecast")) + geom_line(aes(y = actual, color = "Actual")) + geom_point(aes(y = actual, color = "Actual")) + labs( title = "6-step-ahead forecast with 95% confidence intervals", x = "Step ahead", y = "value", color = NULL ) ``` ![plot of chunk intervals-multistep](figure/intervals-multistep-1.png) These are *marginal* per-step intervals: each step's uncertainty is computed on its own, without modeling how errors at different steps co-move (e.g. an under-forecast at step 3 tending to also mean an under-forecast at step 4). ## Step 7: Joint (Correlated) Prediction Intervals `predict(..., type = "pred_int", joint = TRUE)` captures that co-movement. Instead of a single symmetric band per step, each forecast step's own epistemic (weight) uncertainty is combined with a noise term that is sampled *jointly* across steps, using the empirical covariance of training residuals across the forecast horizon (the classic "seemingly unrelated regression" treatment of several linear outputs sharing one design matrix). The result is a set of correlated, internally-consistent sample trajectories rather than independent per-step guesses. Rather than pre-summarizing these into another `.pred_lower`/`.pred_upper` band, the result is returned as raw draws tagged with a `.draw` column, the same convention `tidybayes` and the wider tidyverse Bayesian ecosystem use for "several posterior/predictive samples per observation", so you can compute whatever joint or marginal summary you need with standard `dplyr`/`tidyr` tools. ``` r preds_joint <- predict( fit_obj, new_data = test_data, type = "pred_int", joint = TRUE, n_draws = 200 ) #> 2/2 - 0s - 50ms/step one_row_draws <- preds_joint$.pred[[1]] one_row_draws #> # A tibble: 1,200 × 3 #> .draw .step .pred #> #> 1 1 1 0.892 #> 2 2 1 0.918 #> 3 3 1 0.869 #> 4 4 1 0.912 #> 5 5 1 0.777 #> 6 6 1 0.871 #> 7 7 1 0.891 #> 8 8 1 0.855 #> 9 9 1 0.998 #> 10 10 1 0.961 #> # ℹ 1,190 more rows # Draws at different steps are correlated, unlike the marginal intervals above. one_row_draws |> tidyr::pivot_wider(names_from = .step, values_from = .pred, names_prefix = "step_") |> dplyr::select(step_1, step_2) |> cor() #> step_1 step_2 #> step_1 1.0000000 0.2738393 #> step_2 0.2738393 1.0000000 ``` A handful of individual sampled trajectories, plotted alongside the marginal band from Step 6, shows what the correlation buys you: real trajectories tend to stay consistently above or below the mean forecast across steps, rather than jittering independently step to step the way the marginal band alone would suggest. ``` r sample_paths <- one_row_draws |> dplyr::filter(.draw <= 20) ggplot(sample_paths, aes(.step, .pred, group = .draw)) + geom_line(alpha = 0.3) + geom_ribbon( data = comparison_ci, aes(x = .step, y = .pred, ymin = .pred_lower, ymax = .pred_upper), inherit.aes = FALSE, alpha = 0.15, fill = "blue" ) + geom_line( data = comparison_ci, aes(x = .step, y = .pred), inherit.aes = FALSE, color = "blue", linewidth = 1 ) + labs( title = "20 correlated sample trajectories vs. the marginal band", x = "Step ahead", y = "value" ) ``` ![plot of chunk joint-paths-plot](figure/joint-paths-plot-1.png) Epistemic (weight) uncertainty is still treated independently per step even here; only the aleatoric noise term carries cross-step correlation. A fully joint treatment of epistemic uncertainty too would need a joint (Kronecker-factored) posterior over the entire last-layer weight matrix, which is not implemented. `joint = TRUE` is only available for `type = "pred_int"`; `type = "conf_int"` reflects epistemic uncertainty only, which this implementation has no estimated cross-step correlation source for. ## Limitations This is a v1 building block, not a full forecasting framework. In particular: * **One ordered series at a time.** `step_sequence()` windows the incoming data as a single series; grouped/panel forecasting (many independent series, e.g. per-store or per-sensor) is not handled automatically and would need windowing done per group before this recipe. * **Fixed window and horizon.** `timesteps` and `horizon` (via `output_units`) are set once per spec, not tuned automatically across varying window lengths. * **Epistemic uncertainty is always per-step.** `joint = TRUE` (Step 7) correlates the aleatoric noise across steps, but each step's own model-weight uncertainty is still computed independently. ## Conclusion `step_sequence()` and `step_lead()` let a standard `tidymodels` recipe produce the `(samples, timesteps, features)` input and `(samples, horizon)` target shapes a recurrent `kerasnip` model needs, so a multi-step forecaster fits into the same `recipe() |> workflow() |> fit() |> predict()` flow as any other model in this package.