Skip to contents

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".

Usage

contributions(media, model, intercept = NULL, index = NULL, by = NULL)

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 model when 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 the period column 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