Splits a fitted model's prediction into a per-channel contribution plus a baseline. This is what a marketing mix model deliverable actually is: not the coefficients, but the answer to "how much of last year's revenue did each channel produce, and what would have happened anyway".
Arguments
- media
A data frame of the transformed media regressors – adstocked and saturated – exactly as they entered the model. Column names must match the model's coefficient names.
- model
A fitted model, or a named numeric vector of coefficients. Anything
stats::coef()works on will do.- intercept
Model intercept. Taken from
modelwhen it has one; supply it explicitly when passing a bare coefficient vector without an"(Intercept)"element.- index
Optional vector, the same length as
nrow(media), labelling the periods. Used as theperiodcolumn of the result.- by
Optional grouping vector, the same length as
nrow(media).
Value
A data frame in long format with columns period, group (present
only when by is supplied), channel and contribution. The baseline
appears as a channel named "(baseline)".
When model is a fitted object that stats::fitted() understands,
contributions sum exactly to the model's fitted value in every period,
because the baseline is computed as the fitted value minus the media
effect. When model is a bare coefficient vector there are no fitted
values to work from, so the baseline is just the intercept and
contributions sum to the media effect plus that intercept – the function
says so when it happens.
Details
Contributions are computed as \(\beta_j x_{jt}\) for each channel, with
everything else – intercept, trend, price, seasonality, controls – pooled
into "(baseline)". That pooling is a deliberate simplification: it is what
makes contributions sum exactly to the fitted value, and it means the
baseline is not "organic demand" but "everything this decomposition does not
attribute to media". Reporting it as organic demand is the single most common
way a marketing mix deliverable overstates its own precision.
Contributions inherit whatever the model's identification is worth. If two
channels are collinear the split between them is arbitrary even when the
total is well estimated, and if the model dropped a coefficient outright
this function refuses rather than reporting NA contributions that would
quietly propagate into an ROI table. Run diagnose_media() before
presenting any of this.
On panel data, pass one geography at a time or supply by. index is used
as a label, not a key: with by supplied and one date per geography, a
tapply() over period alone silently adds the geographies together.
The result has class mm_contributions, with a plot()
method.
Examples
data(mm_weekly)
north <- mm_weekly[mm_weekly$geo == "north", ]
channels <- c("tv", "video", "search", "social", "display")
# Transform with the parameters the data was generated from, so the example
# shows a correctly specified decomposition rather than a misspecified one.
truth <- attr(mm_weekly, "truth")
transformed <- as.data.frame(Map(
function(x, d, h, sh) media_transform(
x, adstock = list(decay = d),
saturation = list(half_max = h, shape = sh)),
north[channels], truth$decay[channels], truth$half_max[channels],
truth$shape[channels]
))
# Include the controls the data was generated with. Without them, the media
# coefficients absorb price, trend and seasonality and two of them flip sign.
model_data <- transformed
model_data$week <- seq_len(nrow(model_data))
model_data$price <- north$price
model_data$seasonality <- north$seasonality
model_data$holiday <- north$holiday
fit <- stats::lm(north$revenue ~ ., data = model_data)
contrib <- contributions(transformed, fit, index = north$date)
head(contrib)
#> period channel contribution
#> 1 2023-01-02 tv 0.0000
#> 2 2023-01-09 tv 0.0000
#> 3 2023-01-16 tv 265.1047
#> 4 2023-01-23 tv 206.4877
#> 5 2023-01-30 tv 160.4697
#> 6 2023-02-06 tv 471.9819
# Contributions sum exactly to the model's fitted value in every period
totals <- as.numeric(tapply(contrib$contribution, contrib$period, sum))
all.equal(totals, unname(stats::fitted(fit)))
#> [1] TRUE
# Channel totals over the three years. "(baseline)" is everything the model
# predicts that is not attributed to these five columns -- trend, price,
# seasonality and the intercept. It is not organic demand.
round(tapply(contrib$contribution, contrib$channel, sum))
#> (baseline) display search social tv video
#> 3190425 40853 208307 132709 349629 102461