Specify priors and transformations

Start with a model whose assumptions you can explain. In an ordinary MMM, transformed channel terms, controls, an intercept and optional effects form the linear predictor; the link and likelihood determine how it relates to outcomes.

Adstock and saturation

Adstock describes carryover on the observation grid, so l_max is measured in model periods, not calendar days. GeometricAdstock offers geometric decay; other exported transformations include delayed and Weibull forms. Normalisation, lag direction and parameterisation affect the meaning of the amplitude prior.

LogisticSaturation describes a bounded response to transformed exposure. The default adstock_first=True applies carryover before saturation. Changing the order changes the model; it is not a cosmetic option. Examine plausible impulse responses and response curves before interpreting either parameter. Carryover and shape modelling provides a methodological reference, not evidence that this dataset identifies the selected transformations.

Priors in scaled units

This standalone constructor example changes the intercept, channel amplitude and observation-noise priors. It does not build or fit a graph.

from pymc_extras.prior import Prior
from ammm.mmm import MMM, GeometricAdstock, LogisticSaturation

model = MMM(
    date_column="date", channel_columns=["tv", "social"],
    adstock=GeometricAdstock(l_max=4), saturation=LogisticSaturation(),
    model_config={
        "intercept": Prior("Normal", mu=0, sigma=1),
        "saturation_beta": Prior("HalfNormal", sigma=0.5, dims="channel"),
        "likelihood": Prior("Normal", sigma=Prior("HalfNormal", sigma=0.2)),
    },
)

The numbers are illustrative. Express external knowledge in the model’s scaled units, inspect implied outcomes and vary consequential assumptions. A positive amplitude prior constrains signs, so a positive interval alone is not independent evidence against negative effects.

Controls, seasonality and time variation

control_columns adds linear control terms. Choose them from the adjustment argument, not by maximising fit. Standardise them explicitly when appropriate, using training-only statistics that can also be applied to future inputs.

yearly_seasonality specifies the number of annual Fourier modes. The time_varying_intercept and time_varying_media options admit time variation through a Hilbert-space Gaussian-process approximation, either using configured defaults or a supported HSGP object. Smoothness and amplitude priors determine which variation the baseline can absorb. Extra flexibility can compete with media effects, so compare predictions and channel estimates under defensible alternatives. Respect the finite approximation domain when forecasting.

Holiday effects and custom effects are separate capabilities with their own persistence and response contracts. See holidays, custom effects, pooling and prior sensitivity.

Prepare a labelled panel

This self-contained construction example creates one row per date and geography. It adds a training-standardised control to an ordinary MMM; shared input dates and complete labels do not by themselves create partial pooling or causal identification (src/ammm/mmm/data_conversion.py:189, src/ammm/mmm/mmm.py:1375). The numbers are synthetic.

import numpy as np
import pandas as pd
from ammm.mmm import MMM, GeometricAdstock, LogisticSaturation

rng = np.random.default_rng(17)
panel = pd.DataFrame({
    "date": np.repeat(pd.date_range("2024-01-01", periods=52, freq="W-MON"), 2),
    "geo": ["north", "south"] * 52,
    "tv": rng.uniform(5, 80, 104),
    "price": rng.uniform(9, 12, 104),
})
price_mean, price_sd = panel["price"].mean(), panel["price"].std(ddof=0)
panel["price_z"] = (panel["price"] - price_mean) / price_sd
panel["revenue"] = 100 + 20 * np.tanh(panel["tv"] / 40) + rng.normal(0, 2, 104)
assert not panel.duplicated(["date", "geo"]).any()
panel_model = MMM(
    date_column="date", target_column="revenue", channel_columns=["tv"],
    control_columns=["price_z"], dims=("geo",),
    adstock=GeometricAdstock(l_max=4), saturation=LogisticSaturation(),
)
panel_model.build_model(panel.drop(columns=["revenue", "price"]), panel["revenue"])

For a real holdout, compute price_mean and price_sd on training rows only, reject zero standard deviation, and apply those same values to future rows. Retain both statistics with the model inputs because controls are not standardised by MMM (src/ammm/mmm/_mmm_graph.py:298). Use the pooling example when a learned hierarchy is intended.

Compare transform and baseline assumptions

For the default geometric component, trailing normalised weights are proportional to alpha**lag over the retained lag horizon; the default alpha prior is Beta(1, 3). The logistic component is beta * tanh(lam * x / 2), with default lam ~ Gamma(3, 1) and beta ~ HalfNormal(2) in model units (src/ammm/mmm/components/adstock.py:425, src/ammm/mmm/components/saturation.py:245). An increased lag horizon changes both the retained history and normalisation, so compare implied curves rather than interpreting an alpha value without its horizon.

This constructor pair continues the panel data above. Each member is a separate candidate; the second changes both carryover and shape, so report it as specification sensitivity rather than solely prior sensitivity.

from ammm.mmm import NoAdstock, HillSaturation

shape_alternative = MMM(
    date_column="date", target_column="revenue", channel_columns=["tv"],
    dims=("geo",), adstock=NoAdstock(l_max=1), saturation=HillSaturation(),
)
shape_alternative.build_model(panel.drop(columns="revenue"), panel["revenue"])

Compare prior predictive support, retained fit diagnostics, independent-period predictions and the channel quantities used for decisions. A better in-sample fit cannot select a causal response shape by itself; the exported transform constructors are listed in the API reference.

Build a seasonal and smooth-baseline candidate

This continues panel and builds an ordinary MMM with two annual Fourier modes and a smooth intercept. The reduced basis size is illustrative, not a universal approximation recommendation; this is graph construction, not a fitted comparison.

from ammm.hsgp_kwargs import HSGPKwargs

smooth_model = MMM(
    date_column="date", target_column="revenue", channel_columns=["tv"],
    dims=("geo",), adstock=GeometricAdstock(l_max=4),
    saturation=LogisticSaturation(), yearly_seasonality=2,
    time_varying_intercept=True,
    model_config={"intercept_tvp_config": HSGPKwargs(m=30, ls_mu=5, ls_sigma=10)},
)
smooth_model.build_model(panel.drop(columns="revenue"), panel["revenue"])

Fourier coefficients default to Laplace(0, 1) and ordinary control coefficients to Normal(0, 2); boolean time-variation defaults use 200 basis functions, L=None, eta_lam=1, ls_mu=5, ls_sigma=10 (src/ammm/mmm/mmm.py:1375). Hold the likelihood, data split and response estimand fixed when comparing basis sizes, domain choices and smoothness priors. Inspect prior trajectories and posterior contribution changes as well as prediction: a smooth baseline can absorb the same low-frequency variation as media. Forecasts must respect the fitted HSGP approximation domain (src/ammm/mmm/tvp.py:183, src/ammm/mmm/hsgp.py:64).

Implementation reference at 7cb7f20: src/ammm/mmm/mmm.py:377, src/ammm/mmm/_mmm_graph.py:182, src/ammm/mmm/components/adstock.py:348.