Adstock and Saturation

PanelMMM requires one adstock transform and one saturation transform. ammm applies them inside the model graph rather than as a fixed preprocessing step.

For the econometrics framing of these transforms, see Adstock and Saturation for Econometricians.

How the transform path works

ammm combines the two transforms through forward_pass(...):

  • if adstock_first=True, the order is adstock then saturation
  • if adstock_first=False, the order is saturation then adstock

The transformed result becomes channel_contribution on the model scale.

Adstock options

PanelMMM accepts any AdstockTransformation. The built-in options include:

ClassKey parameter priors by defaultNotes
GeometricAdstockalpha ~ Beta(1, 3)Standard geometric carryover
BinomialAdstockalpha ~ Beta(1, 3)Alternative finite-lag carryover
DelayedAdstockalpha ~ Beta(1, 3), theta ~ HalfNormal(1)Allows a delayed peak
WeibullPDFAdstocklam ~ Gamma(mu=2, sigma=1), k ~ Gamma(mu=3, sigma=1)Flexible PDF-shaped carryover
WeibullCDFAdstocklam ~ Gamma(mu=2, sigma=2.5), k ~ Gamma(mu=2, sigma=2.5)Flexible CDF-shaped carryover

All adstock transforms also take:

  • l_max: maximum lag
  • normalize: whether the carryover weights are normalised
  • mode: convolution mode

Half-life parameterisation

GeometricAdstock and DelayedAdstock also accept parametrization="halflife". In this mode ammm samples a positive halflife parameter and derives alpha; it does not sample both. An explicit half-life prior is required. The legacy alpha parameterisation remains the default when parametrization is omitted.

For geometric adstock, halflife is the number of model periods after which a weight falls to half its initial value: alpha = exp(-log(2) / halflife). For delayed adstock, it is the half-width at half maximum around theta: alpha = exp(-log(2) / halflife**2). It is not the geometric half-life. l_max remains the finite lag window in either parameterisation.

import math

from pymc_extras.prior import Prior

from ammm.mmm import GeometricAdstock

adstock = GeometricAdstock(
    l_max=16,
    parametrization="halflife",
    priors={"halflife": Prior("LogNormal", mu=math.log(4), sigma=0.35)},
)

Saturation options

PanelMMM accepts any SaturationTransformation. Common built-ins include:

ClassKey parameter priors by defaultNotes
LogisticSaturationlam ~ Gamma(alpha=3, beta=1), beta ~ HalfNormal(2)Default retained choice
MichaelisMentenSaturationalpha ~ Gamma(mu=2, sigma=1), lam ~ HalfNormal(1)Common diminishing-returns form
HillSaturationslope ~ HalfNormal(1.5), kappa ~ HalfNormal(1.5), beta ~ HalfNormal(1.5)Flexible Hill curve
HillSaturationSigmoidsigma ~ HalfNormal(1.5), beta ~ HalfNormal(1.5), lam ~ HalfNormal(1.5)Sigmoid Hill variant
RootSaturationalpha ~ Beta(alpha=1, beta=2), beta ~ Gamma(mu=1, sigma=1)Square-root style curvature
TanhSaturationb ~ HalfNormal(1), c ~ HalfNormal(1)Hyperbolic tangent form
TanhSaturationBaselinedx0, gain, r, beta all HalfNormal(1)Baselined tanh form

Default prior dims

When you pass a transform to PanelMMM, ammm assigns default prior dims for any transform prior that does not already have explicit dims:

  • adstock priors default to (*dims, "channel")
  • saturation priors default to (*dims, "channel")

If you want a different structure, set the prior dims explicitly on the transform.

Configure transforms in Python

from ammm.mmm import GeometricAdstock, LogisticSaturation
from ammm.mmm.panel import PanelMMM

mmm = PanelMMM(
    date_column="date",
    channel_columns=["tv", "search"],
    target_column="sales",
    adstock=GeometricAdstock(l_max=8),
    saturation=LogisticSaturation(),
    adstock_first=True,
)

Example with more customised transforms:

from pymc_extras.prior import Prior

from ammm.mmm import MichaelisMentenSaturation, WeibullCDFAdstock

adstock = WeibullCDFAdstock(
    l_max=12,
    priors={
        "lam": Prior("Gamma", mu=2, sigma=1, dims=("geo", "channel")),
        "k": Prior("Gamma", mu=3, sigma=1, dims=("geo", "channel")),
    },
)

saturation = MichaelisMentenSaturation(
    priors={
        "alpha": Prior("Gamma", mu=2, sigma=1, dims=("geo", "channel")),
        "lam": Prior("HalfNormal", sigma=1, dims="geo"),
    }
)

Configure transforms in YAML

data:
  date_column: date

target:
  column: sales
  type: revenue

media:
  channels: [tv, search]
  adstock:
    type: geometric
    l_max: 8
  saturation:
    type: logistic

For a YAML half-life declaration, use the user-facing period median. ammm converts median: 4 to mu: log(4) for a LogNormal prior.

media:
  channels: [tv, search]
  adstock:
    type: geometric
    l_max: 16
    parametrization: halflife
    priors:
      halflife:
        distribution: LogNormal
        median: 4
        sigma: 0.35
        dims: [channel]
  saturation:
    type: logistic

See examples/half_life_yaml_quickstart.py for a runnable configuration check.

Override transform priors through priors

Transform priors also appear in model_config under prefixed variable names. For example:

  • adstock_alpha
  • adstock_halflife when half-life mode is active
  • adstock_lam
  • adstock_k
  • saturation_lam
  • saturation_beta

That means you can override transform priors centrally through the top-level priors if you prefer. See Priors and Configuration.

Choose the composition order

adstock_first is part of the model specification, not a plotting choice.

The current public YAML schema does not expose adstock_first; it uses the library default. If you need to change the composition order, use the Python API.

Use adstock_first=True when you want the model to interpret carryover before diminishing returns. Use False when you want each period’s spend to saturate before the carryover step.

The code path is explicit:

  • True -> saturation(adstock(x))
  • False -> adstock(saturation(x))

Common pitfalls

  • Forgetting that l_max is required for adstock classes
  • Assuming dims automatically change transform priors even when you have already set explicit incompatible dims on the transform
  • Using adstock_first=False without a substantive reason
  • Treating transform priors as if they were on original business units rather than the model scale

Next steps