Spreads each period's media forward in time with geometric decay, the
standard representation of advertising carryover. This is the forward-facing
half of the package's spine; credit_time_decay() is the same kernel run
backward.
Usage
adstock_geometric(
x,
decay,
max_lag = Inf,
normalise = TRUE,
state = 0,
by = NULL,
na_action = c("error", "zero", "keep")
)Arguments
- x
Numeric vector of media spend (or impressions, or GRPs) in time order.
xmust already be sorted by time and evenly spaced; the function has no index argument and cannot check this for you. Usestep_adstock()if you want the time index validated.- decay
Geometric decay coefficient in
[0, 1]. Seedecay_from_half_life()for the half-life vocabulary.- max_lag
Number of periods the kernel spans. The default
Infuses the infinite (recursive) form; a finite value truncates the kernel. See Details.- normalise
Should the kernel sum to 1? Defaults to
TRUE. See the Normalisation section.- state
Carryover already in flight at the start of
x, for chaining calls across contiguous chunks of a series. The default0is a cold start, meaning no media ran beforexbegan. For the infinite kernel this is a single number; for a finitemax_lagit is the precedingmax_lag - 1values ofx. Obtain it fromadstock_state().When
byis supplied, pass either the scalar0(orNULL) to cold-start every group, or a named list with one entry per group – which is the shapeadstock_state()returns when it is givenby. A list with aNULLentry is an error rather than a silent cold start for that group.- by
Optional grouping vector, or data frame of grouping vectors, the same length as
x. Adstock is applied independently within each group, which is what geo-level and panel models need. Never rely ondplyr::group_by()for this: grouping metadata does not reliably survive into every context where this function is called.- na_action
What to do about missing values in
x."error"(the default) refuses to guess."zero"treats missing media as no media, which is usually right for spend but is a substantive assumption."keep"letsNApropagate through the filter, which for the recursive form poisons every subsequent value.
Value
A numeric vector the same length as x, in the same order. Row count
and order are preserved unconditionally.
Normalisation
This is the argument that most often changes an answer without anyone noticing, so it is explicit rather than implied.
With normalise = TRUE the kernel weights sum to 1. A constant spend of
\(c\) adstocks to \(c\), the series level is preserved, and a downstream
regression coefficient reads as the effect of one unit of media, directly
comparable to the coefficient on untransformed spend.
With normalise = FALSE you get raw Koyck accumulation,
\(a_t = x_t + \theta a_{t-1}\). A constant spend of \(c\) accumulates to
\(c/(1-\theta)\), so the series level rises with the decay rate, and the
coefficient reads as the effect of one unit of accumulated media. Both are
defensible; comparing a coefficient fitted one way against a coefficient
fitted the other is not.
Both conventions are in use. Robyn's geometric adstock is the unnormalised recursion; the Bayesian MMM of Jin et al. (2017) normalises its kernel weights to sum to 1. This package defaults to normalised because it keeps the coefficient interpretable and keeps decay and coefficient magnitude from trading off against each other during fitting.
Infinite versus truncated kernels
max_lag = Inf runs the recursive filter, an infinite impulse response. Its
entire memory is one number per series, which is what makes
step_adstock() able to warm-start across a train/test boundary without
storing any training rows.
A finite max_lag truncates to a finite impulse response, which needs the
preceding max_lag - 1 observations as state. Truncation is a modelling
choice, not an approximation to be minimised: use effective_window() to see
how much of the kernel a given max_lag retains.
Note that decay = 1 with normalise = TRUE and max_lag = Inf is an
error, not an edge case: an undecaying infinite kernel has infinite mass and
cannot be normalised.
Causality
The filter is strictly causal. adstock_geometric(x)[1:m] is identical to
adstock_geometric(x[1:m]) for every m, so no future spend can leak into a
past adstock value. This is what makes it safe to adstock a full series once
and then slice it into cross-validation folds, as tune_carryover() does.
References
Broadbent, S. (1979). One way TV advertisements work. Journal of the Market Research Society, 21(3), 139–166.
Jin, Y., Wang, Y., Sun, Y., Chan, D. and Koehler, J. (2017). Bayesian methods for media mix modeling with carryover and shape effects. Google Inc.
See also
adstock_weibull() for delayed peaks, adstock_state() for
chaining, media_transform() to compose with saturation,
credit_time_decay() for the mirrored backward kernel.
Examples
spend <- c(100, 0, 0, 0, 0, 0)
# A single burst decaying forward through time
round(adstock_geometric(spend, decay = 0.5), 2)
#> [1] 50.00 25.00 12.50 6.25 3.12 1.56
# Normalisation preserves the level of a constant series. The equality is
# asymptotic: from a cold start the filter needs a few periods to fill up,
# so take a value well after the burn-in.
constant <- rep(100, 100)
tail(adstock_geometric(constant, decay = 0.8), 1) # 100
#> [1] 100
tail(adstock_geometric(constant, decay = 0.8, normalise = FALSE), 1) # 500
#> [1] 500
# Half-life vocabulary
adstock_geometric(spend, decay = decay_from_half_life(2))
#> [1] 29.28932 20.71068 14.64466 10.35534 7.32233 5.17767
# Dark weeks: carryover decays across a flight gap rather than resetting
flighted <- c(100, 100, 0, 0, 0, 0, 100)
round(adstock_geometric(flighted, decay = 0.6), 2)
#> [1] 40.00 64.00 38.40 23.04 13.82 8.29 44.98
# Geo-level panel data
spend_panel <- c(100, 50, 25, 200, 100, 50)
geo <- c("north", "north", "north", "south", "south", "south")
round(adstock_geometric(spend_panel, decay = 0.5, by = geo), 2)
#> [1] 50.0 50.0 37.5 100.0 100.0 75.0