# Post-Processing Multi-Output and Multistep Models with tailor and probably

## Why single-output models “just work” and multi-output/multistep models don’t

`tailor` and `probably` are the tidymodels tools for post-processing model predictions: calibrating probabilities, adjusting classification thresholds, and building conformal prediction intervals. Both packages are built around a single assumption: one outcome column, one estimate column. `tailor::fit()` selects them with `[[`, which requires exactly one column each.

For a kerasnip model with a single outcome, this is exactly what `predict()` already produces (`.pred` for regression; `.pred_class`/`.pred_<level>` for classification), so `tailor` and `probably` work unmodified — see the Prediction Intervals with Conformal Inference vignette for `probably`, and the examples below for `tailor`.

kerasnip also supports two shapes that go beyond a single outcome, and neither fits `tailor`/`probably`’s assumption:

- Multi-output models (a recipe like `output_1 + output_2 ~ .`, each outcome its own Keras head) produce `.pred_output_1`, `.pred_output_2`, … — this is the standard `parsnip::maybe_multivariate()` shape for multivariate regression, not something kerasnip invented, but downstream post-processing tooling hasn’t caught up to it for any engine yet.
- Multistep forecasting models (see `vignette("multistep_forecasting")`) produce a nested `.pred` list-column: one inner tibble per row, holding `.step` and the forecasted value at each step. `tailor::check_variable_type()` requires `is.numeric()` on the outcome/estimate columns, which a list-column fails outright.

kerasnip cannot make `workflows::add_tailor()` work directly on either shape — `tailor::fit()`’s single-column selection is baked into its own code, not something kerasnip’s prediction format can work around. Instead, kerasnip provides:

- `kerasnip_output_view()` / `kerasnip_step_view()`: present one output (or one forecast step) as an ordinary single-output fit, so you can use `tailor`/`probably` exactly as documented, one output/step at a time.
- `kerasnip_add_tailor()`: a `workflows::add_tailor()`-alike built on top of the views, for when you want the tailor trained and applied automatically as part of `fit()`/`predict()`.

## Setup

    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(tailor)
    library(probably)
    #>
    #> Attaching package: 'probably'
    #> The following objects are masked from 'package:base':
    #>
    #>     as.factor, as.ordered

## Multi-output models

### Defining a multi-output regression workflow

    input_block <- function(input_shape) keras3::layer_input(shape = input_shape)
    dense_block <- function(tensor, units = 16) {
      tensor |> keras3::layer_dense(units = units, activation = "relu")
    }
    output_1_block <- function(tensor) keras3::layer_dense(tensor, units = 1, name = "temperature")
    output_2_block <- function(tensor) keras3::layer_dense(tensor, units = 1, name = "humidity")

    create_keras_functional_spec(
      model_name = "climate_mlp",
      layer_blocks = list(
        main_input = input_block,
        dense      = inp_spec(dense_block, "main_input"),
        temperature = inp_spec(output_1_block, "dense"),
        humidity    = inp_spec(output_2_block, "dense")
      ),
      mode = "regression"
    )

    spec <- climate_mlp(dense_units = 16, fit_epochs = 30) |>
      set_engine("keras")

    set.seed(1)
    n <- 300
    climate_data <- tibble(
      pressure    = rnorm(n),
      wind_speed  = rnorm(n),
      temperature = pressure + rnorm(n, sd = 0.2),
      humidity    = -0.5 * wind_speed + rnorm(n, sd = 0.2)
    )

    rec <- recipe(temperature + humidity ~ pressure + wind_speed, data = climate_data)

    split <- initial_split(climate_data, prop = 0.7)
    train_dat <- training(split)
    cal_dat   <- testing(split)

    wflow <- workflow(rec, spec)
    fit_obj <- fit(wflow, data = train_dat)
    #> 7/7 - 0s - 19ms/step
    #> 7/7 - 0s - 17ms/step

    predict(fit_obj, new_data = cal_dat[1:5, ])
    #> 1/1 - 0s - 97ms/step
    #> # A tibble: 5 × 2
    #>   .pred_temperature .pred_humidity
    #>               <dbl>          <dbl>
    #> 1           -0.172          -0.247
    #> 2           -0.528           0.814
    #> 3            0.234          -0.541
    #> 4            0.0706          0.245
    #> 5           -0.337           0.232

Both outcomes come back from a single `predict()` call, `.pred_temperature`/`.pred_humidity` — exactly parsnip’s own convention for multivariate regression, and exactly what breaks `tailor`/`probably`, which need one estimate column to work with.

### `kerasnip_output_view()`: one output as a standard single-output fit

    temp_view <- kerasnip_output_view(fit_obj, "temperature")
    predict(temp_view, new_data = cal_dat[1:5, ])
    #> 1/1 - 0s - 48ms/step
    #> # A tibble: 5 × 1
    #>     .pred
    #>     <dbl>
    #> 1 -0.172
    #> 2 -0.528
    #> 3  0.234
    #> 4  0.0706
    #> 5 -0.337

`temp_view` behaves like an ordinary single-output fit to anything that calls `predict()` on it. That is enough for manual `tailor` usage:

    cal_preds <- predict(temp_view, new_data = cal_dat)
    #> 3/3 - 0s - 41ms/step
    cal_data_for_tailor <- bind_cols(temperature = cal_dat$temperature, cal_preds)

    tlr <- tailor() |> adjust_numeric_calibration(method = "linear")
    tlr_fit <- fit(tlr, cal_data_for_tailor, outcome = temperature, estimate = .pred)
    #> Registered S3 method overwritten by 'butcher':
    #>   method                 from
    #>   as.character.dev_topic generics

    new_preds <- predict(temp_view, new_data = cal_dat[1:5, ])
    #> 1/1 - 0s - 81ms/step
    predict(tlr_fit, new_preds)
    #> # A tibble: 5 × 1
    #>     .pred
    #>     <dbl>
    #> 1 -0.239
    #> 2 -0.481
    #> 3  0.178
    #> 4 -0.0144
    #> 5 -0.353

It is also enough for `probably`’s conformal methods, because `kerasnip_output_view()` implements `hardhat::extract_mold()` and `generics::augment()` for the view, which is all `probably::int_conformal_split()` needs:

    conformal <- int_conformal_split(temp_view, cal_data = cal_dat)
    #> 3/3 - 0s - 42ms/step
    predict(conformal, new_data = cal_dat[1:5, ], level = 0.90)
    #> 1/1 - 0s - 59ms/step
    #> # A tibble: 5 × 3
    #>     .pred .pred_lower .pred_upper
    #>     <dbl>       <dbl>       <dbl>
    #> 1 -0.172       -0.555      0.211
    #> 2 -0.528       -0.911     -0.145
    #> 3  0.234       -0.149      0.617
    #> 4  0.0706      -0.312      0.454
    #> 5 -0.337       -0.720      0.0465

### `probably::int_conformal_full()`: supported, with one documented assumption

`int_conformal_full()` refits the model once per candidate value of every new observation. For a multi-output model, refitting means retraining the whole multi-head network — so what should the other output(s) be during that refit, for a row where they were never observed in the first place? `kerasnip_output_view()`’s `int_conformal_full()` support substitutes the model’s own current point-prediction for the other output(s) as a placeholder. Because the placeholder equals what that head already predicts, its loss contribution for that one synthetic row is ~zero, so it should not be measurably disturbed while the target head still responds to the candidate value under test. This is a reasonable choice, not a proven one — treat the resulting intervals accordingly.

    # Small subset to keep runtime reasonable in this vignette.
    small_train <- train_dat[1:40, ]
    small_new   <- cal_dat[1:3, ]

    fit_small <- fit(wflow, data = small_train)
    #> 2/2 - 0s - 119ms/step
    #> 2/2 - 0s - 108ms/step
    temp_view_small <- kerasnip_output_view(fit_small, "temperature")

    conformal_full <- int_conformal_full(
      temp_view_small,
      train_data = small_train,
      control = control_conformal_full(method = "grid", trial_points = 15)
    )
    #> 2/2 - 0s - 121ms/step
    predict(conformal_full, new_data = small_new, level = 0.90)
    #> 1/1 - 0s - 71ms/step
    #> 1/1 - 0s - 82ms/step
    #> 2/2 - 0s - 102ms/step
    #> 2/2 - 0s - 78ms/step
    #> 2/2 - 0s - 80ms/step
    #> 2/2 - 0s - 64ms/step
    #> 2/2 - 0s - 83ms/step
    #> 2/2 - 0s - 70ms/step
    #> 2/2 - 0s - 55ms/step
    #> 2/2 - 0s - 55ms/step
    #> 2/2 - 0s - 63ms/step
    #> 2/2 - 0s - 65ms/step
    #> 2/2 - 0s - 56ms/step
    #> 2/2 - 0s - 62ms/step
    #> 2/2 - 0s - 58ms/step
    #> 2/2 - 0s - 56ms/step
    #> 2/2 - 0s - 61ms/step
    #> 2/2 - 0s - 70ms/step
    #> 2/2 - 0s - 58ms/step
    #> 2/2 - 0s - 66ms/step
    #> 2/2 - 0s - 56ms/step
    #> 2/2 - 0s - 59ms/step
    #> 2/2 - 0s - 66ms/step
    #> 2/2 - 0s - 72ms/step
    #> 2/2 - 0s - 58ms/step
    #> 2/2 - 0s - 76ms/step
    #> 2/2 - 0s - 65ms/step
    #> 2/2 - 0s - 65ms/step
    #> 2/2 - 0s - 60ms/step
    #> 2/2 - 0s - 53ms/step
    #> 2/2 - 0s - 100ms/step
    #> 2/2 - 0s - 69ms/step
    #> 2/2 - 0s - 59ms/step
    #> 2/2 - 0s - 53ms/step
    #> 2/2 - 0s - 69ms/step
    #> 2/2 - 0s - 68ms/step
    #> 2/2 - 0s - 73ms/step
    #> 2/2 - 0s - 62ms/step
    #> 2/2 - 0s - 57ms/step
    #> 2/2 - 0s - 56ms/step
    #> 2/2 - 0s - 63ms/step
    #> 2/2 - 0s - 54ms/step
    #> 2/2 - 0s - 49ms/step
    #> 2/2 - 0s - 66ms/step
    #> 2/2 - 0s - 64ms/step
    #> 2/2 - 0s - 45ms/step
    #> 2/2 - 0s - 61ms/step
    #> 1/1 - 0s - 49ms/step
    #> 2/2 - 0s - 52ms/step
    #> 2/2 - 0s - 53ms/step
    #> 2/2 - 0s - 63ms/step
    #> 2/2 - 0s - 53ms/step
    #> 2/2 - 0s - 55ms/step
    #> 2/2 - 0s - 64ms/step
    #> 2/2 - 0s - 63ms/step
    #> 2/2 - 0s - 51ms/step
    #> 2/2 - 0s - 63ms/step
    #> 2/2 - 0s - 56ms/step
    #> 2/2 - 0s - 57ms/step
    #> 2/2 - 0s - 69ms/step
    #> 2/2 - 0s - 59ms/step
    #> 2/2 - 0s - 51ms/step
    #> 2/2 - 0s - 74ms/step
    #> 2/2 - 0s - 55ms/step
    #> 2/2 - 0s - 58ms/step
    #> 2/2 - 0s - 64ms/step
    #> 2/2 - 0s - 51ms/step
    #> 2/2 - 0s - 67ms/step
    #> 2/2 - 0s - 85ms/step
    #> 2/2 - 0s - 52ms/step
    #> 2/2 - 0s - 65ms/step
    #> 2/2 - 0s - 59ms/step
    #> 2/2 - 0s - 57ms/step
    #> 2/2 - 0s - 47ms/step
    #> 2/2 - 0s - 51ms/step
    #> 2/2 - 0s - 53ms/step
    #> 2/2 - 0s - 52ms/step
    #> 2/2 - 0s - 65ms/step
    #> 2/2 - 0s - 55ms/step
    #> 2/2 - 0s - 58ms/step
    #> 2/2 - 0s - 67ms/step
    #> 2/2 - 0s - 53ms/step
    #> 2/2 - 0s - 53ms/step
    #> 2/2 - 1s - 312ms/step
    #> 2/2 - 0s - 56ms/step
    #> 2/2 - 0s - 53ms/step
    #> 2/2 - 0s - 68ms/step
    #> 2/2 - 0s - 55ms/step
    #> 2/2 - 0s - 55ms/step
    #> 2/2 - 0s - 71ms/step
    #> 2/2 - 0s - 66ms/step
    #> 2/2 - 0s - 53ms/step
    #> 2/2 - 0s - 52ms/step
    #> 1/1 - 0s - 44ms/step
    #> 2/2 - 0s - 43ms/step
    #> 2/2 - 0s - 57ms/step
    #> 2/2 - 0s - 67ms/step
    #> 2/2 - 0s - 53ms/step
    #> 2/2 - 0s - 48ms/step
    #> 2/2 - 0s - 60ms/step
    #> 2/2 - 0s - 61ms/step
    #> 2/2 - 0s - 49ms/step
    #> 2/2 - 0s - 51ms/step
    #> 2/2 - 0s - 51ms/step
    #> 2/2 - 0s - 50ms/step
    #> 2/2 - 0s - 61ms/step
    #> 2/2 - 0s - 56ms/step
    #> 2/2 - 0s - 54ms/step
    #> 2/2 - 0s - 59ms/step
    #> 2/2 - 0s - 60ms/step
    #> 2/2 - 0s - 61ms/step
    #> 2/2 - 0s - 61ms/step
    #> 2/2 - 0s - 53ms/step
    #> 2/2 - 0s - 52ms/step
    #> 2/2 - 0s - 46ms/step
    #> 2/2 - 0s - 48ms/step
    #> 2/2 - 0s - 53ms/step
    #> 2/2 - 0s - 50ms/step
    #> 2/2 - 0s - 51ms/step
    #> 2/2 - 0s - 51ms/step
    #> 2/2 - 0s - 64ms/step
    #> 2/2 - 0s - 52ms/step
    #> 2/2 - 0s - 64ms/step
    #> 2/2 - 0s - 64ms/step
    #> 2/2 - 0s - 53ms/step
    #> 2/2 - 0s - 61ms/step
    #> 2/2 - 0s - 67ms/step
    #> 2/2 - 0s - 53ms/step
    #> 2/2 - 0s - 52ms/step
    #> 2/2 - 0s - 62ms/step
    #> 2/2 - 0s - 48ms/step
    #> 2/2 - 0s - 52ms/step
    #> 2/2 - 0s - 62ms/step
    #> 2/2 - 0s - 54ms/step
    #> 2/2 - 0s - 53ms/step
    #> 2/2 - 0s - 63ms/step
    #> 2/2 - 0s - 51ms/step
    #> 2/2 - 0s - 51ms/step
    #> 2/2 - 0s - 58ms/step
    #> # A tibble: 3 × 2
    #>   .pred_lower .pred_upper
    #>         <dbl>       <dbl>
    #> 1      -0.610      0.241
    #> 2      -1.11       0.0851
    #> 3      -0.375      0.665

Only `method = "grid"` is supported; `"iterative"` relies on `probably`’s private root-finding internals and is out of scope.

### `kerasnip_add_tailor()`: attach and forget

For routine use, `kerasnip_add_tailor()` wraps the view + fit + splice-back steps into the same `add_tailor()`-style workflow you would use for a single-output model — except it targets one named output, and every other output’s columns pass through untouched:

    tlr2 <- tailor() |> adjust_numeric_calibration(method = "linear")
    tailored_wf <- kerasnip_add_tailor(wflow, tlr2, output = "temperature")

    tailored_fit <- fit(tailored_wf, data = train_dat, data_calibration = cal_dat)
    #> 7/7 - 0s - 15ms/step
    #> 7/7 - 0s - 15ms/step
    #> 3/3 - 0s - 41ms/step
    predict(tailored_fit, new_data = cal_dat[1:5, ])
    #> 1/1 - 0s - 47ms/step
    #> 1/1 - 0s - 63ms/step
    #> # A tibble: 5 × 2
    #>   .pred_temperature .pred_humidity
    #>               <dbl>          <dbl>
    #> 1           -0.261          -0.165
    #> 2           -0.451           0.805
    #> 3            0.201          -0.515
    #> 4            0.0167          0.160
    #> 5           -0.308           0.191

`.pred_temperature` is calibrated; `.pred_humidity` is exactly what a plain, un-tailored `predict()` would have returned.

## Multistep forecasting models

A multistep model (see `vignette("multistep_forecasting")`) has a single outcome conceptually — but `predict()` returns it as a nested `.pred` list-column (one row per sample, one inner tibble per row holding `.step` and the forecasted value), which `tailor`’s `is.numeric()` check rejects just as firmly as a genuine multi-output shape, for an unrelated reason.

    set.seed(42)
    n_steps <- 200
    timesteps <- 12
    horizon <- 4
    series <- tibble(value = sin(seq_len(n_steps) / 10) + rnorm(n_steps, sd = 0.05))

    rec_step <- recipe(series) |>
      step_lead(value, lead = seq_len(horizon), prefix = "lead_") |>
      step_naomit(starts_with("lead_")) |>
      step_sequence(value, timesteps = timesteps, new_col = "window")

    window_input <- function(input_shape) keras3::layer_input(shape = input_shape, name = "window_input")
    lstm_block   <- function(tensor, units = 16) tensor |> keras3::layer_lstm(units = units)
    step_output  <- function(tensor, units = 1) tensor |> keras3::layer_dense(units = units)

    create_keras_functional_spec(
      model_name = "forecast_lstm",
      layer_blocks = list(
        window = window_input,
        lstm   = inp_spec(lstm_block, "window"),
        output = inp_spec(step_output, "lstm")
      ),
      mode = "regression"
    )

    step_spec <- forecast_lstm(lstm_units = 16, output_units = horizon, fit_epochs = 30) |>
      set_engine("keras")

    split_step <- initial_time_split(series, prop = 0.8)
    train_series <- training(split_step)
    test_series  <- testing(split_step)

    step_wflow <- workflow(rec_step, step_spec)
    step_fit <- fit(step_wflow, data = train_series)
    #> 5/5 - 0s - 85ms/step

    # step_sequence() needs `timesteps` rows of leading history to produce a
    # single prediction, so a preview slice must include at least that much
    # context; this gives 6 rows with a full window.
    preview_data <- test_series[seq_len(timesteps + 5), , drop = FALSE]

    predict(step_fit, new_data = preview_data)
    #> 1/1 - 0s - 205ms/step
    #> # A tibble: 6 × 1
    #>   .pred
    #>   <list>
    #> 1 <tibble [4 × 2]>
    #> 2 <tibble [4 × 2]>
    #> 3 <tibble [4 × 2]>
    #> 4 <tibble [4 × 2]>
    #> 5 <tibble [4 × 2]>
    #> 6 <tibble [4 × 2]>

### `kerasnip_step_view()`: one forecast step as a standard single-output fit

    step_2_view <- kerasnip_step_view(step_fit, step = 2)
    predict(step_2_view, new_data = preview_data)
    #> 1/1 - 0s - 45ms/step
    #> # A tibble: 6 × 1
    #>    .pred
    #>    <dbl>
    #> 1 -0.997
    #> 2 -0.973
    #> 3 -0.935
    #> 4 -0.884
    #> 5 -0.830
    #> 6 -0.767

Unlike a multi-output model, a multistep model’s per-step outcome columns (`lead_2_value`, …) are recipe-engineered from a single raw column by `step_lead()` — they do not exist in raw data the way `output_1`/`output_2` do for a genuine multi-output model. `kerasnip_step_truth()` recovers the true future value at a given step by re-baking the fitted recipe:

    truth <- kerasnip_step_truth(step_2_view, test_series)
    head(truth)
    #> [1] -0.9795517 -0.9710722 -0.9111726 -0.8897428 -0.8943562 -0.8710572

That is enough to calibrate manually, same as the multi-output case:

    preds_step <- predict(step_2_view, new_data = train_series)
    #> 5/5 - 0s - 49ms/step
    truth_step <- kerasnip_step_truth(step_2_view, train_series)

    cal_tbl <- tibble(truth = truth_step, .pred = preds_step$.pred) |>
      filter(!is.na(truth))

    tlr_step <- tailor() |> adjust_numeric_calibration(method = "linear")
    tlr_step_fit <- fit(tlr_step, cal_tbl, outcome = truth, estimate = .pred)

    new_preds_step <- predict(step_2_view, new_data = preview_data)
    #> 1/1 - 0s - 53ms/step
    predict(tlr_step_fit, new_preds_step)
    #> # A tibble: 6 × 1
    #>    .pred
    #>    <dbl>
    #> 1 -1.01
    #> 2 -0.986
    #> 3 -0.946
    #> 4 -0.893
    #> 5 -0.837
    #> 6 -0.772

…and enough for `probably::int_conformal_split()`, exactly as with a multi-output view:

    conformal_step <- int_conformal_split(step_2_view, cal_data = train_series)
    #> 5/5 - 0s - 15ms/step
    predict(conformal_step, new_data = preview_data, level = 0.90)
    #> 1/1 - 0s - 46ms/step
    #> # A tibble: 6 × 3
    #>    .pred .pred_lower .pred_upper
    #>    <dbl>       <dbl>       <dbl>
    #> 1 -0.997      -1.09       -0.908
    #> 2 -0.973      -1.06       -0.884
    #> 3 -0.935      -1.02       -0.846
    #> 4 -0.884      -0.974      -0.795
    #> 5 -0.830      -0.919      -0.740
    #> 6 -0.767      -0.856      -0.677

`probably::int_conformal_full()` is also supported for step views, with a different design from the multi-output case: a multistep model’s step targets are not independent raw columns — every `lead_k_value` column is derived from the same single raw column by `step_lead()`. Testing a candidate value for one step means writing that candidate into the raw column at the appropriate future offset, which also (partially) supplies the targets for the other steps forecast from the same origin; those other steps’ placeholders are the current model’s own forecast, the same idea as the multi-output case’s “other output(s)” placeholder. This is only supported when `step_lead()` and `step_sequence()` share a single source column, true of the model built above (and every multistep example in this package).

    # Small subset to keep runtime reasonable in this vignette, but wide enough
    # for the residual-variance model to see a representative range of
    # predictions — too narrow a range makes it extrapolate wildly for new
    # observations outside it. small_new needs at least `timesteps` rows of
    # leading context, same as preview_data above.
    small_train <- train_series[1:80, , drop = FALSE]
    small_new <- test_series[seq_len(timesteps + 2), , drop = FALSE]

    fit_small <- fit(step_wflow, data = small_train)
    #> 3/3 - 0s - 140ms/step
    step_2_view_small <- kerasnip_step_view(fit_small, step = 2)

    conformal_step_full <- int_conformal_full(
      step_2_view_small,
      train_data = small_train,
      control = control_conformal_full(method = "grid", trial_points = 10)
    )
    #> 3/3 - 0s - 147ms/step
    predict(conformal_step_full, new_data = small_new, level = 0.90)
    #> 1/1 - 0s - 67ms/step
    #> 1/1 - 0s - 44ms/step
    #> 3/3 - 0s - 144ms/step
    #> 3/3 - 0s - 119ms/step
    #> 3/3 - 0s - 144ms/step
    #> 3/3 - 0s - 141ms/step
    #> 3/3 - 0s - 129ms/step
    #> 3/3 - 0s - 129ms/step
    #> 3/3 - 0s - 123ms/step
    #> 3/3 - 0s - 137ms/step
    #> 3/3 - 0s - 136ms/step
    #> 3/3 - 0s - 157ms/step
    #> 3/3 - 0s - 130ms/step
    #> 3/3 - 0s - 129ms/step
    #> 3/3 - 1s - 171ms/step
    #> 3/3 - 0s - 135ms/step
    #> 3/3 - 0s - 143ms/step
    #> 3/3 - 0s - 140ms/step
    #> 3/3 - 0s - 136ms/step
    #> 3/3 - 0s - 117ms/step
    #> 3/3 - 0s - 132ms/step
    #> 3/3 - 1s - 404ms/step
    #> 3/3 - 0s - 136ms/step
    #> 3/3 - 0s - 130ms/step
    #> 3/3 - 0s - 124ms/step
    #> 3/3 - 0s - 124ms/step
    #> 3/3 - 0s - 120ms/step
    #> 3/3 - 0s - 116ms/step
    #> 3/3 - 0s - 125ms/step
    #> 3/3 - 0s - 124ms/step
    #> 3/3 - 0s - 123ms/step
    #> 3/3 - 0s - 107ms/step
    #> 3/3 - 0s - 106ms/step
    #> 3/3 - 0s - 110ms/step
    #> 3/3 - 0s - 134ms/step
    #> 3/3 - 0s - 112ms/step
    #> 3/3 - 0s - 100ms/step
    #> 3/3 - 0s - 116ms/step
    #> 3/3 - 0s - 103ms/step
    #> 3/3 - 0s - 121ms/step
    #> 3/3 - 0s - 127ms/step
    #> 3/3 - 0s - 110ms/step
    #> 3/3 - 0s - 124ms/step
    #> 3/3 - 0s - 104ms/step
    #> 3/3 - 0s - 122ms/step
    #> 3/3 - 0s - 118ms/step
    #> 3/3 - 0s - 109ms/step
    #> 3/3 - 0s - 91ms/step
    #> 3/3 - 0s - 101ms/step
    #> 3/3 - 0s - 105ms/step
    #> 3/3 - 0s - 113ms/step
    #> 3/3 - 0s - 119ms/step
    #> 3/3 - 0s - 98ms/step
    #> 3/3 - 0s - 101ms/step
    #> 3/3 - 0s - 96ms/step
    #> 3/3 - 0s - 100ms/step
    #> 3/3 - 0s - 110ms/step
    #> 3/3 - 0s - 111ms/step
    #> 3/3 - 0s - 95ms/step
    #> 3/3 - 0s - 104ms/step
    #> 3/3 - 0s - 94ms/step
    #> 3/3 - 0s - 86ms/step
    #> # A tibble: 3 × 2
    #>   .pred_lower .pred_upper
    #>         <dbl>       <dbl>
    #> 1       -1.49      -0.610
    #> 2       -1.45      -0.607
    #> 3       -1.40      -0.603

As with the multi-output case, only `method = "grid"` is supported.

### `kerasnip_add_tailor()` for one forecast step

    tlr_step2 <- tailor() |> adjust_numeric_calibration(method = "linear")
    tailored_step_wf <- kerasnip_add_tailor(step_wflow, tlr_step2, step = 2)

    tailored_step_fit <- fit(tailored_step_wf, data = train_series)
    #> 5/5 - 0s - 67ms/step
    #> 5/5 - 0s - 71ms/step
    predict(tailored_step_fit, new_data = preview_data)
    #> 1/1 - 0s - 30ms/step
    #> 1/1 - 0s - 41ms/step
    #> # A tibble: 6 × 1
    #>   .pred
    #>   <list>
    #> 1 <tibble [4 × 2]>
    #> 2 <tibble [4 × 2]>
    #> 3 <tibble [4 × 2]>
    #> 4 <tibble [4 × 2]>
    #> 5 <tibble [4 × 2]>
    #> 6 <tibble [4 × 2]>

Step 2’s forecasted value is calibrated in every row’s nested tibble; every other step is left exactly as a plain `predict()` would have returned it.

## Cleanup

    remove_keras_spec("climate_mlp")
    #> Removed from parsnip registry objects: climate_mlp, climate_mlp_args, climate_mlp_encoding, climate_mlp_fit, climate_mlp_modes, climate_mlp_pkgs, climate_mlp_predict
    #> Removed 'climate_mlp' from parsnip:::get_model_env()$models
    remove_keras_spec("forecast_lstm")
    #> Removed from parsnip registry objects: forecast_lstm, forecast_lstm_args, forecast_lstm_encoding, forecast_lstm_fit, forecast_lstm_modes, forecast_lstm_pkgs, forecast_lstm_predict
    #> Removed 'forecast_lstm' from parsnip:::get_model_env()$models