Applies geometric adstock to one or more columns inside a recipes pipeline, carrying the filter's state across the train/test boundary instead of emitting missing values there.
Usage
step_adstock(
recipe,
...,
index = NULL,
by = NULL,
decay = 0.5,
max_lag = Inf,
normalise = TRUE,
carry_over = c("auto", "warm", "cold"),
na_action = c("zero", "error"),
tol = 0.25,
role = NA,
trained = FALSE,
columns = NULL,
states = NULL,
last_index = NULL,
period = NULL,
skip = FALSE,
id = .mm_rand_id("adstock")
)Arguments
- recipe
A recipe object.
- ...
One or more selector functions choosing the media columns.
- index
Name of the time column, as a string. Required: adstock is a time-ordered filter, so the step needs to know the order and the spacing. The column is used for ordering and validation and is not itself transformed.
- by
Optional character vector naming grouping columns. Adstock is applied independently within each group, which is what geo-level and panel models need. This is an explicit argument because recipes has no group awareness and a grouped data frame does not reliably survive into
bake().- decay
Geometric decay coefficient in
[0, 1]. Tunable.- max_lag
Kernel length;
Inf(the default) uses the infinite recursive form. Tunable.- normalise
Should the kernel sum to 1? See
adstock_geometric().- carry_over
How to treat state at the start of new data.
"auto"(the default) warm-starts when the new data continues the training series and cold-starts otherwise, warning when it does."warm"errors rather than silently cold-starting on a gap or an unseen group; data that overlaps the training period still cold-starts, since re-baking data the step has already seen should reproduce the prepared output."cold"always restarts from zero, which reproduces the behaviour of a lag-based step.- na_action
What to do about missing media values.
"zero"(the default) treats missing media as no media, which is usually right for spend and is what lets a recipe run over a real, patchy panel without stopping. It is still a substantive assumption – anNAbecomes a real number in the output – so"error"is available when you would rather find out. Note this differs fromadstock_geometric(), whose default is"error"; a step has to survive resampling, and a bare function call does not.- tol
Relative tolerance on the step between periods when deciding whether two blocks are contiguous. The default of
0.25is loose so that calendar months, which vary from 28 to 31 days, are not mistaken for gaps.- role
Not used by this step, since new columns are not created.
- trained
Has the step been prepared?
- columns, states, last_index, period
Populated by
prep(); not set directly.- skip
Should the step be skipped when baking? Must remain
FALSE: adstock is a predictor transform and has to run at prediction time.- id
A unique step identifier.
The problem this solves
Adstock is a filter with memory. Prepare a recipe on January to June and bake
it on July to December, and the first rows of July have no history to draw
on. recipes::step_lag() answers this by emitting NA, silently; its
documented remedy, step_naomit(), changes the row count, which breaks
fit_resamples() outright. That is not an escape hatch, it is a dead end.
The way out is to store filter state rather than rows. Geometric adstock is
an infinite impulse response filter whose entire memory is one number per
series,
$$a_t = x_t + \theta a_{t-1},$$
so prep() stores one double per column per group. That is \(O(1)\) state.
It retains no training observations, so it adds nothing meaningful to the
size of a fitted workflow and carries no raw data with it. Only the truncated
forms, where max_lag is finite, need an actual tail of max_lag - 1
values.
What bake() does at the boundary
prep() records, per group, the terminal filter state, the last time index
seen, and the period inferred from the median spacing of the index. bake()
then compares the start of the new data against that record:
- Exactly contiguous
Warm-start from the stored state. The result is identical to filtering the whole series at once, with no prepended rows.
- Overlaps the training data
Cold-start. This is what catches
bake(rec, new_data = training), which would otherwise double-count the training period's own carryover.- A gap
Cold-start, and warn, naming the gap size and the group.
- An unseen group
Cold-start, and warn.
Row count and row order are preserved in every case. The step sorts by
index internally and restores the original order before returning, so
nothing downstream sees a reordered table.
Resampling and leakage
A reviewer will ask whether stored state leaks across a resampling boundary.
It does not. Under any rsample scheme, prep() re-runs on each split's
analysis set, so the state is re-learned from that split's training rows
alone and describes only media that precedes the assessment set. The
information that crosses the boundary is past media spend, which is
genuinely known at prediction time; no outcome information crosses at all.
Note also that this step is strictly causal, unlike recipes::step_window()
and recipes::step_impute_roll(), which are centred and fill the leading
edge of a series using future values. Applied to a media filter, that would
leak future spend into past adstock and inflate in-sample fit, which is the
exact pathology marketing mix models are already accused of.
Examples
library(recipes)
#> Loading required package: dplyr
#>
#> Attaching package: ‘dplyr’
#> The following objects are masked from ‘package:stats’:
#>
#> filter, lag
#> The following objects are masked from ‘package:base’:
#>
#> intersect, setdiff, setequal, union
#>
#> Attaching package: ‘recipes’
#> The following object is masked from ‘package:stats’:
#>
#> step
data(mm_weekly)
train <- mm_weekly[mm_weekly$date < as.Date("2025-01-01"), ]
test <- mm_weekly[mm_weekly$date >= as.Date("2025-01-01"), ]
rec <- recipe(revenue ~ ., data = train) |>
step_adstock(tv, video, search, index = "date", by = "geo", decay = 0.7) |>
prep()
# Row count and order are preserved; no NA at the boundary
baked <- bake(rec, new_data = test)
nrow(baked) == nrow(test)
#> [1] TRUE
sum(is.na(baked$tv))
#> [1] 0
# The stored state is one number per column per geo
tidy(rec, number = 1)
#> terms group decay state state_length id
#> 1 tv central 0.7 1731.6052 1 adstock_H5dyz
#> 2 video central 0.7 758.2532 1 adstock_H5dyz
#> 3 search central 0.7 1094.0381 1 adstock_H5dyz
#> 4 tv north 0.7 1709.3561 1 adstock_H5dyz
#> 5 video north 0.7 1173.6509 1 adstock_H5dyz
#> 6 search north 0.7 1504.2630 1 adstock_H5dyz
#> 7 tv south 0.7 5923.2555 1 adstock_H5dyz
#> 8 video south 0.7 1331.0873 1 adstock_H5dyz
#> 9 search south 0.7 2185.7450 1 adstock_H5dyz