Model Specification

This section explains how PanelMMM is defined: the model structure, media transforms, priors, panel dimensions, optional time variation, and calibration hooks.

Pages

  • Model Overview - The actual PanelMMM mean structure, scaled-space formulation, and optional components.
  • Adstock and Saturation - Supported media transforms, their priors, and the adstock_first composition order.
  • Priors and Configuration - Default prior keys, model_config, transform-prior overrides, and directional control priors.
  • Time-Varying Parameters - How time_varying_intercept and time_varying_media use SoftPlusHSGP.
  • Seasonality and Trends - Built-in yearly seasonality plus custom Fourier, trend, and event effects.
  • Panel Dimensions - How dims change the shape of the data and parameters.
  • Choose an Estimator - Compare the released time-series, FE, and CRE contracts and choose the identifying variation that matches the modelling question.
  • Fixed-effects Estimator - The released one-unit FE contract, its within-design checks, and its limits.
  • Correlated-random-effects Estimator
    • The released one-unit CRE contract, its transformed summary basis, estimability checks, and interpretation limits.
  • Calibration - Lift-test and cost-per-target calibration for a built model.

Subsections of Model Specification

Model Overview

PanelMMM is an additive Bayesian marketing mix model built in PyMC. This page describes the model structure that Abacus actually builds.

For input layout, see Data Preparation. For individual configuration surfaces, see the other pages in this section. Named FE and CRE presets use specialised likelihoods and restrictions; read Choose an Estimator before applying this general component description to them.

Core structure

At fit time, Abacus builds the model mean in scaled target space as:

mu =
  intercept_contribution
  + sum(channel_contribution over channel)
  + sum(control_contribution over control), if control_columns are configured
  + mundlak_contribution, if use_mundlak_cre=True
  + yearly_seasonality_contribution, if yearly_seasonality is enabled
  + any additional mu_effects

The observed target is then attached through the configured likelihood distribution with mu=mu.

What is scaled and what is not

Before the PyMC graph is built:

  • channel data is scaled according to Scaling.channel
  • the target is scaled according to Scaling.target
  • controls are not scaled automatically

That means media and target priors operate on the model scale, not directly on the original business units. For the scaling surface, see Scaling and Preprocessing.

Model components

Component Built when Shape
intercept_contribution Always effectively ("date", *dims) in the model mean
channel_contribution Always ("date", *dims, "channel")
control_contribution control_columns is set ("date", *dims, "control")
mundlak_contribution use_mundlak_cre=True dims
yearly_seasonality_contribution yearly_seasonality is set ("date", *dims)
Additional additive effects You add entries to mu_effects ("date", *dims)

Abacus also adds total_media_contribution_original_scale automatically as a deterministic on the original target scale.

Media path

Each channel column goes through the configured media transform path:

  1. scale channel input
  2. apply adstock and saturation through forward_pass(...)
  3. optionally apply a time-varying media multiplier
  4. contribute the result through channel_contribution

See Adstock and Saturation and Time-Varying Parameters.

Controls

Controls enter the model as a separate additive term:

control_contribution = control_data * gamma_control

Use controls for non-media regressors such as price, macro indicators, or competitor measures. Controls are configured with control_columns and use gamma_control priors from model_config.

Panel dimensions

dims adds extra indexing axes such as geo, brand, or market.

With dims=("geo",), the model is indexed over date and geo. With dims=("geo", "brand"), it is indexed over date, geo, and brand.

Abacus does not automatically add hierarchical pooling just because dims is set. By default, parameters are indexed over the configured panel coordinates. If you want hierarchical shrinkage across those coordinates, encode it in the priors you pass to transforms or model_config.

See Panel Dimensions.

Optional components

Need Main setting
Extra non-media regressors control_columns
Legacy low-level Mundlak adjustment use_mundlak_cre=True
Built-in yearly seasonality yearly_seasonality=<int>
Time-varying intercept time_varying_intercept=True or custom HSGPBase
Time-varying media time_varying_media=True or custom HSGPBase
Additional additive effects append to mmm.mu_effects or use YAML effects
Calibration add_lift_test_measurements(...), add_cost_per_target_calibration(...)

This table describes the ordinary PanelMMM component surface. The released FE and CRE presets deliberately reject several optional components and downstream operations. Their estimator pages are authoritative for those boundaries.

What target_type changes

target_type is semantic metadata, not a different likelihood family.

It affects downstream reporting such as the default efficiency metric label:

  • "revenue" -> ROAS
  • "conversion" -> CPA

It does not change the fitted functional form on its own.

Python example

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

mmm = PanelMMM(
    date_column="date",
    target_column="sales",
    channel_columns=["tv", "search"],
    control_columns=["price_index"],
    dims=("geo",),
    yearly_seasonality=2,
    adstock=GeometricAdstock(l_max=8),
    saturation=LogisticSaturation(),
)

This specification gives you:

  • an intercept
  • transformed media contributions for tv and search
  • a control contribution for price_index
  • yearly Fourier seasonality
  • a panel axis over geo

Next steps

Adstock and Saturation

PanelMMM requires one adstock transform and one saturation transform. Abacus 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

Abacus 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:

Class Key parameter priors by default Notes
GeometricAdstock alpha ~ Beta(1, 3) Standard geometric carryover
BinomialAdstock alpha ~ Beta(1, 3) Alternative finite-lag carryover
DelayedAdstock alpha ~ Beta(1, 3), theta ~ HalfNormal(1) Allows a delayed peak
WeibullPDFAdstock lam ~ Gamma(mu=2, sigma=1), k ~ Gamma(mu=3, sigma=1) Flexible PDF-shaped carryover
WeibullCDFAdstock lam ~ Gamma(mu=2, sigma=1), k ~ Gamma(mu=3, sigma=1) Flexible CDF-shaped carryover

All adstock transforms also take:

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

Saturation options

PanelMMM accepts any SaturationTransformation. Common built-ins include:

Class Key parameter priors by default Notes
LogisticSaturation lam ~ Gamma(alpha=3, beta=1), beta ~ HalfNormal(2) Default retained choice
MichaelisMentenSaturation alpha ~ Gamma(mu=2, sigma=1), lam ~ HalfNormal(1) Common diminishing-returns form
HillSaturation slope ~ HalfNormal(1.5), kappa ~ HalfNormal(1.5), beta ~ HalfNormal(1.5) Flexible Hill curve
HillSaturationSigmoid sigma ~ HalfNormal(1.5), beta ~ HalfNormal(1.5), lam ~ HalfNormal(1.5) Sigmoid Hill variant
RootSaturation alpha ~ Beta(alpha=1, beta=2), beta ~ Gamma(mu=1, sigma=1) Square-root style curvature
TanhSaturation b ~ HalfNormal(1), c ~ HalfNormal(1) Hyperbolic tangent form
TanhSaturationBaselined x0, gain, r, beta all HalfNormal(1) Baselined tanh form

Default prior dims

When you pass a transform to PanelMMM, Abacus 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 abacus.mmm import GeometricAdstock, LogisticSaturation
from abacus.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 abacus.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

Override transform priors through priors

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

  • adstock_alpha
  • 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

Seasonality and Trends

Abacus supports one built-in seasonality switch on PanelMMM and a broader additive-effect mechanism for custom seasonality, trend, and event terms.

Built-in yearly seasonality

Set yearly_seasonality=<int> to add a yearly Fourier term directly to the main model specification.

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

This creates:

  • fourier_contribution as the unsummed Fourier basis contribution
  • yearly_seasonality_contribution as the additive contribution to mu

The prior for this built-in seasonality comes from model_config["gamma_fourier"].

What yearly_seasonality means

yearly_seasonality is the Fourier order passed to YearlyFourier.

It must be a positive integer. Abacus validates this at construction time.

Custom additive effects

For anything beyond built-in yearly seasonality, use mu_effects.

Each effect must add a tensor with dims:

("date", *dims)

Abacus ships three retained additive-effect types:

Effect Use it for
FourierEffect Custom seasonal structure such as weekly or monthly Fourier terms
LinearTrendEffect Piecewise linear trend with changepoints
EventAdditiveEffect Dated events such as launches, promotions, or holidays

FourierEffect

FourierEffect wraps a FourierBase implementation such as:

  • YearlyFourier
  • MonthlyFourier
  • WeeklyFourier

Example:

from abacus.mmm.additive_effect import FourierEffect
from abacus.mmm.fourier import WeeklyFourier

mmm.mu_effects.append(
    FourierEffect(
        fourier=WeeklyFourier(n_order=3, prefix="weekly_fourier")
    )
)

LinearTrendEffect

LinearTrendEffect wraps LinearTrend, which models piecewise linear trend changes through changepoints.

Example:

from abacus.mmm import LinearTrend
from abacus.mmm.additive_effect import LinearTrendEffect

mmm.mu_effects.append(
    LinearTrendEffect(
        trend=LinearTrend(
            n_changepoints=8,
            include_intercept=False,
            dims=("geo",),
        ),
        prefix="trend",
    )
)

Events

For events, the retained public surface on PanelMMM is add_events(...).

Example:

import pandas as pd

from pymc_extras.prior import Prior

from abacus.mmm.events import EventEffect, GaussianBasis

df_events = pd.DataFrame(
    {
        "name": ["promo", "launch"],
        "start_date": pd.to_datetime(["2025-02-01", "2025-03-10"]),
        "end_date": pd.to_datetime(["2025-02-07", "2025-03-14"]),
    }
)

effect = EventEffect(
    basis=GaussianBasis(
        priors={"sigma": Prior("Gamma", mu=7, sigma=1, dims="event")}
    ),
    effect_size=Prior("Normal", mu=0, sigma=1, dims="event"),
    dims=("event",),
)

mmm.add_events(
    df_events=df_events,
    prefix="event",
    effect=effect,
)

The event effect dims must include the event prefix plus the model dims.

When to register effects

Add custom effects before you build or fit the model.

That applies to:

  • mmm.mu_effects.append(...)
  • mmm.add_events(...)

If you build the model first and only then append effects, those new terms are not part of the existing graph.

YAML effects

The YAML builder supports top-level effects: entries. Example:

effects:
  - type: linear_trend
    prefix: trend
    n_changepoints: 8
    include_intercept: false
  - type: weekly_fourier
    order: 3
    prefix: weekly_fourier

The builder appends these effects before calling build_model(...).

Choosing between built-in and custom seasonality

Use yearly_seasonality when you need a compact built-in annual effect.

Use FourierEffect when you need:

  • weekly seasonality
  • monthly seasonality
  • multiple seasonal effects together
  • custom Fourier prefixes or priors

Common pitfalls

  • Adding effects after the model has already been built
  • Using event effect dims that do not include the required prefix
  • Treating yearly_seasonality and a custom yearly Fourier effect as if they were separate concepts when they are both additive seasonal terms

Next steps

  • Read Time-Varying Parameters if you want trend or media behaviour to vary smoothly over time.
  • Read Calibration if you want to constrain the specification with external measurements.

Priors and Configuration

Abacus uses model_config to control priors on the underlying PyMC variables. Transform priors can be configured either on the transform objects themselves or through their prefixed variable names in model_config.

Where configuration lives

Surface Use it for
model_config Intercept, likelihood, controls, seasonality, Mundlak terms, time-varying config, and prefixed transform priors
adstock=... and saturation=... Transform choice plus direct transform-prior overrides
control_impacts and control_sign_policy Directional expectations for controls

Default model_config

PanelMMM.default_model_config is built from the current model state.

The default keys are:

Key Default
intercept Prior("Normal", mu=0, sigma=2, dims=dims)
likelihood Prior("Normal", sigma=Prior("HalfNormal", sigma=2, dims=dims), dims=("date", *dims))
gamma_control Prior("Normal", mu=0, sigma=2, dims=(*dims, "control"))
gamma_fourier Prior("Laplace", mu=0, b=1, dims=(*dims, "fourier_mode"))
gamma_channel_mundlak Added only when use_mundlak_cre=True
gamma_control_mundlak Added only when use_mundlak_cre=True
intercept_tvp_config Added when time_varying_intercept is enabled
media_tvp_config Added when time_varying_media is enabled

Abacus also merges in the transform-specific config exposed by the selected adstock and saturation objects.

Configure priors in Python

Use pymc_extras.prior.Prior objects when you want explicit control:

from pymc_extras.prior import Prior

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

model_config = {
    "intercept": Prior("Normal", mu=0, sigma=1, dims=("geo",)),
    "likelihood": Prior(
        "Normal",
        sigma=Prior("HalfNormal", sigma=1.5, dims=("geo",)),
        dims=("date", "geo"),
    ),
    "gamma_control": Prior("Normal", mu=0, sigma=1, dims=("geo", "control")),
    "saturation_lam": Prior("Gamma", alpha=3, beta=1, dims=("geo", "channel")),
}

mmm = PanelMMM(
    date_column="date",
    target_column="sales",
    channel_columns=["tv", "search"],
    control_columns=["price_index"],
    dims=("geo",),
    adstock=GeometricAdstock(l_max=8),
    saturation=LogisticSaturation(),
    model_config=model_config,
)

Configure priors in YAML

YAML config can express the same priors as serialised distribution mappings:

data:
  date_column: date

target:
  column: sales
  type: revenue

dimensions:
  panel: [geo]

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

priors:
  intercept:
    distribution: Normal
    mu: 0
    sigma: 1
    dims: ["geo"]
  likelihood:
    distribution: Normal
    sigma:
      distribution: HalfNormal
      sigma: 1.5
      dims: ["geo"]
    dims: ["date", "geo"]
  saturation_lam:
    distribution: Gamma
    alpha: 3
    beta: 1
    dims: ["geo", "channel"]

Abacus parses these mappings into runtime Prior or HSGPKwargs objects.

Transform priors and prefixed names

Transform parameters appear in the model under prefixed variable names.

Examples:

  • adstock alpha -> adstock_alpha
  • saturation lam -> saturation_lam
  • saturation beta -> saturation_beta

So you can override transform priors in either of these ways:

  1. pass priors={...} to the transform object
  2. override the prefixed variable in model_config

Use one style consistently within a project if you want the configuration to be easy to read.

Directional control priors

Controls are the right place for exogenous drivers whose effect may be negative, such as competitor spend, competitor price pressure, or supply-side disruptions. By default, control coefficients remain unrestricted.

You can declare expected control directions with:

  • control_impacts
  • control_sign_policy

Allowed impact values:

  • positive
  • negative
  • unrestricted

Allowed policies:

  • soft: bias the prior toward the expected sign
  • strict: use a sign-constrained prior

Python example

mmm = PanelMMM(
    date_column="date",
    channel_columns=["tv", "search"],
    control_columns=["competitor_spend", "price_index"],
    control_impacts={
        "competitor_spend": "negative",
        "price_index": "negative",
    },
    control_sign_policy="strict",
    adstock=GeometricAdstock(l_max=8),
    saturation=LogisticSaturation(),
)

YAML note

The current public YAML schema does not expose control_impacts or control_sign_policy. If you need directional control settings today, use the Python API for that part of the specification.

Constraints for directional controls

When control_impacts is configured, Abacus expects:

  • gamma_control and gamma_control_mundlak to be Normal or TruncatedNormal
  • scalar numeric mu and sigma values for those priors
  • the prior dims to include "control"

If you violate those assumptions, model build fails with a validation error.

Time-varying configuration keys

When you enable a boolean time-varying effect, Abacus uses these model_config keys:

  • intercept_tvp_config
  • media_tvp_config

Those keys can be:

  • an HSGPKwargs instance
  • a dict with HSGPKwargs fields
  • a dict in SoftPlusHSGP.parameterize_from_data(...) style, such as {"ls_lower": 1, "ls_upper": 10}

See Time-Varying Parameters.

Important scope note

Directional control priors apply to control_columns, not channel_columns. Media channels are modelled through the adstock and saturation path.

If you need full manual control over the control prior, override gamma_control and gamma_control_mundlak directly in model_config.

Common pitfalls

  • Putting control priors on media variables instead of using transform priors
  • Forgetting the prefixed transform variable names in model_config
  • Assuming dims automatically create hierarchical priors
  • Using directional control priors with incompatible gamma_control distributions

Next steps

Panel Dimensions

Use dims when your dataset is a panel rather than a single timeseries.

Examples of useful panel dimensions:

  • geo
  • brand
  • market
  • country

For the input row layout, see Panel Data Layout.

What dims does

dims tells PanelMMM which extra categorical axes exist alongside date.

With no extra dims, the model is indexed by:

  • date
  • channel
  • optionally control

With dims=("geo",), the model is indexed by:

  • date
  • geo
  • channel
  • optionally control

With dims=("geo", "brand"), it is indexed by:

  • date
  • geo
  • brand
  • channel
  • optionally control

What changes inside the model

Setting dims changes the coordinates and parameter shapes used in the PyMC graph.

Quantity No extra dims dims=("geo",)
channel_data ("date", "channel") ("date", "geo", "channel")
target_data ("date",) ("date", "geo")
channel_contribution ("date", "channel") ("date", "geo", "channel")
control_contribution ("date", "control") ("date", "geo", "control")
intercept prior dims by default () ("geo",)

Reserved names

Do not use these names in dims:

  • date
  • channel
  • control
  • fourier_mode

Abacus rejects them because they are reserved for internal coordinates.

dims does not imply automatic pooling

This is the most important modelling point.

By default, dims gives you parameters indexed by the panel coordinates, but not automatic hierarchical shrinkage across those coordinates.

For example:

  • the default intercept prior is Normal(..., dims=dims)
  • transform priors default to (*dims, "channel")
  • control coefficients default to (*dims, "control")

Those defaults create per-slice parameters. If you want hierarchical pooling across geo, brand, or another dimension, you need to encode that in the priors you supply.

Example: independent panel slices

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

With this specification, the default priors are geo-indexed, but not hierarchical by default.

Example: explicit hierarchical prior

If you want hierarchical structure, define it in the prior itself.

from pymc_extras.prior import Prior

model_config = {
    "intercept": Prior(
        "Normal",
        mu=0,
        sigma=Prior("HalfNormal", sigma=0.3),
        dims="geo",
    ),
}

You can do the same for transform priors and additive effects.

Legacy Mundlak adjustment and panel dimensions

use_mundlak_cre=True only makes sense when you have at least one panel dim. Abacus enforces that. This low-level surface is not the named CRE estimator preset, and its coefficients do not by themselves identify confounding or establish causal effects.

When enabled, Abacus builds extra correlated-random-effects terms from training period means:

  • channel_mundlak_contribution
  • control_mundlak_contribution
  • mundlak_contribution

These terms live on the panel coordinates defined by dims.

Custom HSGP dims

If you use a custom SoftPlusHSGP for time-varying effects, its dims must be compatible with the panel structure.

Examples:

  • no extra dims: ("date",) or ("date", "channel") for media
  • dims=("geo",): ("date", "geo") or ("date", "geo", "channel") for media

See Time-Varying Parameters.

YAML example

data:
  date_column: date

target:
  column: sales
  type: revenue

dimensions:
  panel: [geo, brand]

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

Your dataset must then contain both geo and brand columns.

Common pitfalls

  • Using reserved names in dims
  • Assuming dims implies automatic partial pooling
  • Enabling use_mundlak_cre with no panel dimensions
  • Forgetting that every date + dims combination must be present in the data

Next steps

Time-Varying Parameters

Abacus supports time-varying intercept and media effects through SoftPlusHSGP, a Hilbert Space Gaussian Process approximation.

Two ways to enable time variation

For both time_varying_intercept and time_varying_media, you can pass:

  • True to use the config-driven default path
  • a custom HSGPBase instance such as SoftPlusHSGP(...)

Boolean mode

The simplest entry point is a boolean flag:

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

When you do this, Abacus builds a SoftPlusHSGP internally from:

  • model_config["intercept_tvp_config"]
  • model_config["media_tvp_config"]

The default config keys are HSGPKwargs with:

  • m=200
  • L=None
  • eta_lam=1
  • ls_mu=5
  • ls_sigma=10
  • cov_func=None

What the boolean defaults mean

With boolean mode:

  • time_varying_intercept=True creates intercept_latent_process over ("date", *dims)
  • time_varying_media=True creates media_temporal_latent_multiplier over ("date", *dims)

That second point matters:

boolean time_varying_media=True gives you one shared temporal multiplier per panel slice, not a different time-varying multiplier per channel

If you want channel-specific time variation, pass a custom HSGP with channel in its dims.

Custom SoftPlusHSGP

Use a custom HSGP instance when you need precise control over dims, covariance, or priors.

Example: channel-specific time-varying media in a simple timeseries model.

import numpy as np

from abacus.mmm import SoftPlusHSGP

n_dates = X["date"].nunique()

media_hsgp = SoftPlusHSGP.parameterize_from_data(
    X=np.arange(n_dates),
    dims=("date", "channel"),
    cov_func="matern32",
)

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

For a panel model with dims=("geo",), valid media HSGP dims include:

  • ("date", "geo")
  • ("date", "geo", "channel")

For the intercept, the custom dims should align with the target axes, typically ("date",) or ("date", *dims).

Supported covariance choices

For SoftPlusHSGP.parameterize_from_data(...), the supported covariance keywords are:

  • "expquad"
  • "matern32"
  • "matern52"

Config formats for boolean mode

The *_tvp_config entries in model_config support two formats.

HSGPKwargs style

from abacus.hsgp_kwargs import HSGPKwargs

model_config = {
    "intercept_tvp_config": HSGPKwargs(
        m=50,
        L=None,
        eta_lam=1.0,
        ls_mu=5.0,
        ls_sigma=10.0,
        cov_func=None,
    )
}

Equivalent dict form is also accepted.

parameterize_from_data style

You can also pass a dict that is forwarded to SoftPlusHSGP.parameterize_from_data(...):

model_config = {
    "intercept_tvp_config": {
        "ls_lower": 1.0,
        "ls_upper": 10.0,
    }
}

Abacus preserves that dict and uses it when constructing the HSGP.

How the latent process enters the model

Time-varying intercept

Abacus creates:

intercept_contribution = intercept_baseline * intercept_latent_process

Time-varying media

Abacus first creates a baseline transformed media contribution, then multiplies it by the temporal latent process:

channel_contribution =
  baseline_channel_contribution * media_temporal_latent_multiplier

If the custom media HSGP dims include channel, the multiplier can vary by channel. Otherwise it is broadcast across channels.

Save and load

Custom SoftPlusHSGP instances round-trip through PanelMMM.save(...) and PanelMMM.load(...).

That includes custom dims such as:

  • ("date",)
  • ("date", "channel")
  • ("date", "geo")
  • ("date", "geo", "channel")

Common pitfalls

  • Expecting time_varying_media=True to create channel-specific media multipliers
  • Using custom HSGP dims that do not align with the model dims
  • Forgetting that boolean mode uses model_config["intercept_tvp_config"] and model_config["media_tvp_config"]

Next steps

Calibration

Calibration lets you add external evidence to a built PanelMMM.

Both calibration methods are unavailable for the named FE, CRE and release-gated RE presets; they raise EstimatorOperationError. Check Choose an Estimator before preparing calibration data. A built graph alone does not establish support.

Abacus currently supports two retained calibration paths:

  • lift-test measurements through add_lift_test_measurements(...)
  • cost-per-target calibration through add_cost_per_target_calibration(...)

General rule

Both calibration methods operate on a built model, not a bare constructor.

Typical sequence:

mmm.build_model(X, y)

# optional calibration step(s) here

idata = mmm.fit(X, y)

If you try to add calibration before the model graph exists, Abacus raises an error.

Lift-test calibration

Use add_lift_test_measurements(...) to add external lift measurements against the modelled saturation behaviour.

df_lift_test = pd.DataFrame(
    {
        "channel": ["tv", "search"],
        "x": [100.0, 80.0],
        "delta_x": [20.0, 10.0],
        "delta_y": [15.0, 6.0],
        "sigma": [3.0, 2.0],
    }
)

mmm.build_model(X, y)
mmm.add_lift_test_measurements(df_lift_test)

Required columns for lift tests

Lift-test data always needs:

  • channel
  • x
  • delta_x
  • delta_y
  • sigma

It also needs:

  • every configured entry in dims
  • any additional coordinate columns required by the calibrated variables

In practice, time-varying media models usually require date, because the time-varying multiplier is indexed by date.

What Abacus does

add_lift_test_measurements(...):

  1. validates the mapping columns
  2. scales the lift-test channel and target values to the model scale
  3. maps the rows to the model coordinates
  4. adds a likelihood term named lift_measurements by default

If time_varying_media is enabled, Abacus includes the media temporal multiplier in the calibrated saturation function automatically.

Practical notes

  • Lift measurements must be monotonic in the sense enforced by the calibration graph helpers.
  • The calibration distribution defaults to pm.Gamma.
  • You can change the registered variable name with name=....

Cost-per-target calibration

Use add_cost_per_target_calibration(...) when you want soft penalties on channel cost-per-target values.

mmm.build_model(X, y)
mmm.add_original_scale_contribution_variable(var=["channel_contribution"])

calibration_data = pd.DataFrame(
    {
        "geo": ["UK", "US"],
        "channel": ["tv", "search"],
        "cost_per_target": [30.0, 45.0],
        "sigma": [2.0, 3.0],
    }
)

mmm.add_cost_per_target_calibration(
    data=X,
    calibration_data=calibration_data,
    name_prefix="cpt_calibration",
)

Required prerequisites

Before you add cost-per-target calibration:

  1. build the model
  2. add channel_contribution_original_scale

The second step is required because cost-per-target calibration operates against original-scale channel contribution.

Required columns for calibration_data

calibration_data must include:

  • channel
  • cost_per_target
  • sigma
  • every configured entry in dims

Requirements for data

The data argument is the spend dataset used to compute calibrated cost per target.

After Abacus reshapes it into xarray form, its coordinates must match the built model’s:

  • same shape
  • same coordinate labels
  • same channel list

If the reshaped spend data does not match the model coordinates, Abacus raises a validation error instead of silently reordering it.

YAML calibration

The YAML builder supports calibration through a top-level calibration: list. Each step must provide an explicit method plus a params mapping.

Supported YAML calibration methods:

  • add_lift_test_measurements
  • add_cost_per_target_calibration

Example:

original_scale_vars:
  - channel_contribution

calibration:
  - method: add_lift_test_measurements
    params:
      df_lift_test:
        dataframe:
          data:
            channel: ["channel_1", "channel_2"]
            x: [100.0, 80.0]
            delta_x: [20.0, 10.0]
            delta_y: [15.0, 6.0]
            sigma: [3.0, 2.0]

Important YAML constraints:

  • calibration steps run after build_model(...)
  • original_scale_vars is applied before calibration
  • only the supported calibration methods above are available in YAML
  • dist is not supported in YAML yet for add_lift_test_measurements
  • other calibration actions should be applied through the Python API until they have explicit YAML support

Choose the right calibration path

Use lift tests when you have measured incremental response data for a specific spend change.

Use cost-per-target calibration when you want the fitted channel contribution to stay consistent with observed cost efficiency.

For a supported estimator, you can use either or both after building the model.

Common pitfalls

  • Adding calibration before build_model(...)
  • Forgetting to add channel_contribution_original_scale before cost-per-target calibration
  • Omitting required dims columns from calibration data
  • Assuming YAML supports every Python calibration argument; dist does not currently round-trip through YAML

Next steps

  • Read Model Fitting for the fit workflow once the model has been fully specified.
  • Read Save and Load if you plan to keep calibrated models on disk.

Correlated-random-effects Estimator

The released cre preset fits a one-unit correlated-random-effects (CRE) marketing-mix model. It combines shared media and control slopes with a Gaussian random unit intercept and an explicit adjustment for association between persistent unit differences and the declared predictors.

Released contract

estimator:
  type: cre
  unit: geo

The v1 surface has these deliberate limits:

Component Released CRE behaviour
Unit dimensions Exactly one categorical unit column
Unit effects Gaussian random intercept, integrated out exactly
Media and control slopes Shared across units
Adstock and saturation Shared geometric adstock followed by logistic saturation
CRE media summaries Centred unit means of the transformed exposure basis
CRE control summaries Standardised centred unit means of eligible time-varying controls
Residual scale Shared across units
Common time effects and seasonality Not supported
Custom additive effects and holidays Not supported
Historical and manual scenarios Supported for the complete fitted-unit panel; fitted CRE summaries remain frozen
Calibration and fixed-budget optimisation Not supported

Use the bundled starting point at data/demo/geo_cre/config.yml.

Run it from the repository root:

python3 runme.py --demo geo_cre

Statistical meaning

For unit i and date t, CRE augments the shared-slope level equation with unit summaries of the declared regressors. The media summaries are computed from the fitted adstock-and-saturation exposure basis. They are not raw-spend means. Eligible control summaries are standardised unit means.

The residual unit intercept is integrated out. The likelihood therefore uses one exact Gaussian covariance block per unit. Pointwise log likelihood is unit-block marginal evidence, not an observation-level or future-date score.

The adjustment relaxes the naive random-effects mean-independence restriction only with respect to the declared summary basis. It does not establish causal identification or address omitted time-varying confounding, measurement error, reverse causality, or response-function misspecification.

Data and estimability requirements

The dataset must be a balanced unit-date Cartesian product with one row per unit and date. It must contain enough units to estimate the active centred media and control summaries while retaining at least two residual between-unit degrees of freedom.

Abacus rejects:

  • non-finite predictors or targets;
  • media with no within-unit temporal variation;
  • an exactly rank-deficient transformed between-summary design; and
  • insufficient between-unit residual degrees of freedom.

It warns about low transformed within-unit variation, high variance inflation factors, and a high condition number. These thresholds are configurable under estimator.estimability. A pass means that the implemented screen found no declared defect. It is not proof of global, posterior-wide, or causal identification.

After fitting, inspect posterior convergence, effective sample size, sampler pathologies, prior sensitivity, the post-fit summary-basis diagnostics, and predictive checks. A CRE coefficient interval containing zero does not prove that a simpler random-effects model is adequate.

Pipeline evidence

A structured CRE run records:

  • the resolved estimator contract in 00_run_metadata/estimator_summary.txt and 00_run_metadata/estimator_manifest.yaml;
  • the raw structural screen in 10_pre_diagnostics/cre_structural_estimability.json;
  • the transformed reference-basis screen in 10_pre_diagnostics/cre_reference_estimability.json and 10_pre_diagnostics/cre_reference_estimability_features.csv;
  • the bounded posterior-draw screen in 20_model_fit/cre_postfit_estimability.json; and
  • the CRE adjustment and reconciliation outputs under 40_decomposition.

The run manifest is the machine-readable index of these files. A completed pipeline only means that every required stage ran. Review the diagnostic status before interpreting the posterior.

Prediction and persistence

Prediction is conditional on the fitted unit history. Every prediction request must supply all fitted units. Row order may vary, but unseen units and fitted-unit subsets are rejected. Save and load preserve the fitted CRE summary state and validate its unit coordinates before prediction.

Historical and manual scenarios use the same fitted-unit restriction. Manual spend changes the nonlinear media-response path, but Abacus does not recompute the fitted training-period Mundlak media or control summaries. This preserves the fitted CRE adjustment rather than redefining confounding context from the planned spend. Scenario outputs are posterior media-contribution estimates, not total-outcome forecasts or causal effects.

Use the YAML and Python examples under data/demo/geo_cre/. Fixed-budget optimisation remains outside the released CRE scenario contract.

Evidence boundary

The release verifies the declared graph, configuration restrictions, estimability evidence, fitted-unit prediction contract and persistence path. It does not promise a fixed point-estimate accuracy, causal validity, or general robustness for arbitrary data. Treat each fitted model as a separate statistical assessment.

For a direct comparison with FE and the aggregate time-series preset, see Choose an Estimator.

Choose an Estimator

PanelMMM provides three released named estimator presets:

  • time_series for one aggregate time series
  • fe for a one-unit fixed-effects panel
  • cre for a one-unit correlated-random-effects panel

The re declaration is typed but release-gated. It is not a runnable estimator.

Release support matrix

Preset Status in 3.1.1 Data contract Identifying variation Important operation boundary
time_series Released One aggregate observation per date Aggregate temporal variation Uses the supported ordinary PanelMMM workflow
fe Released One balanced unit-date panel Within-unit temporal variation Prediction and historical/manual scenarios require all fitted units; calibration and fixed-budget optimisation are unavailable
cre Released One balanced unit-date panel Within-unit variation conditional on the declared transformed between-unit summary adjustment Prediction and historical/manual scenarios require all fitted units and frozen fitted CRE summaries; calibration and fixed-budget optimisation are unavailable
re Gated; not released Declaration only Not applicable until release Graph construction fails closed with EstimatorReleaseGateError

The support status is a software release boundary, not evidence that a preset is appropriate for a particular dataset or causal question.

Compare the released presets

Question time_series fe cre
Data structure One observation per date Balanced unit-date panel Balanced unit-date panel
Unit effects Not applicable Absorbed unit intercepts Gaussian random unit intercept
Identifying variation for shared slopes Aggregate temporal variation Within-unit temporal variation Within-unit temporal variation, conditional on the declared between-unit summary adjustment
Media slopes Shared Shared across units Shared across units
Adstock and saturation Shared Shared across units Shared across units
Persistent unit differences Not represented Removed from the slope likelihood Modelled through the random intercept and declared CRE summaries
Common categorical time effects Not an estimator option Not supported Not supported
Calibration Available through the ordinary PanelMMM surface Not supported Not supported
Historical and manual scenarios Supported Supported for all fitted units Supported for all fitted units with frozen fitted CRE summaries
Fixed-budget optimisation Supported Not supported Not supported

All three presets combine likelihood information with the declared priors. None of them turns observational marketing data into a causal design.

Use the time-series preset

Use time_series when the modelling unit is one aggregate market, brand, or business series and each date occurs once.

estimator:
  type: time_series

Do not use it to disguise panel observations as independent aggregate rows. Aggregate the data deliberately or use a panel estimator.

Use FE

Use fe when the target question concerns changes within units over time and you want time-invariant unit characteristics removed from the shared-slope likelihood.

estimator:
  type: fe
  unit: geo

FE is appropriate only when the transformed media and controls have enough within-unit temporal variation. It cannot estimate coefficients for predictors that are constant within every unit. It also does not remove time-varying confounding or common shocks.

See Fixed-effects Estimator for the exact likelihood and estimability checks.

Use CRE

Use cre when persistent unit differences may be associated with the declared predictors and you need an explicit within-between panel specification.

estimator:
  type: cre
  unit: geo

CRE augments the shared-slope random-intercept model with centred unit summaries. Media summaries use the fitted adstock-and-saturation exposure basis rather than raw-spend means. The adjustment is limited to the declared basis. It does not correct arbitrary omitted confounding.

CRE needs both usable within-unit media variation and enough independent between-unit information for its active summaries. Prediction is limited to the complete set of fitted units.

See Correlated-random-effects Estimator for the exact likelihood, summary basis, estimability checks, and prediction boundary.

Do not choose from fit statistics alone

The presets answer different statistical questions. Do not select one only because it has the best in-sample fit, lowest information criterion, or the most favourable media coefficient.

Before fitting:

  1. State the unit and time structure of the business question.
  2. State which persistent and time-varying confounding paths remain plausible.
  3. Check whether the proposed identifying variation exists after media transformation.
  4. Choose the estimator contract and priors before inspecting the preferred result.

After fitting, inspect the estimator-specific estimability evidence, MCMC diagnostics, posterior predictive checks, prior sensitivity, and any predeclared holdout evidence supported by that estimator. A passed screen means that Abacus did not detect the specified defect. It is not proof of causal or global identification.

Run the bundled recipes

From the repository root:

python3 runme.py --demo timeseries
python3 runme.py --demo geo_fe
python3 runme.py --demo geo_cre

The demo sampling settings are evidence-oriented and may take time. Command-line overrides can reduce the budget for an installation or workflow check. Do not interpret a reduced run as final statistical evidence.

Fixed-effects Estimator

The released fe preset fits a one-unit fixed-effects marketing-mix model. It absorbs a separate intercept for each unit and identifies shared media and control effects from temporal changes within each unit.

For units i and dates t, the level formulation is:

y_it = alpha_i + f(media_it; theta) + controls_it * gamma + error_it.

Abacus fits the equivalent exact within-unit orthonormal-contrast likelihood. The unit intercepts alpha_i are not regularised parameters in the graph. Consequently, persistent differences between units do not identify the shared media coefficients.

Released contract

estimator:
  type: fe
  unit: geo

The released surface has these deliberate limits:

Component Released FE behaviour
Unit dimensions Exactly one categorical unit column
Unit effects Absorbed fixed intercepts
Media and control slopes Shared across units
Adstock and saturation parameters Shared across units
Residual scale Shared across units
Common time effects Not supported
Annual seasonality and custom additive effects Not supported
Time-varying intercepts or media Not supported
Budget optimisation and calibration Not supported

Use the bundled starting point at data/demo/geo_fe/config.yml. It contains only settings supported by this contract.

Run it from the repository root:

python3 runme.py --demo geo_fe

Data requirements

The dataset must be balanced on the declared unit and date columns: every unit must have the same dates, and each unit-date pair must occur once. It must have at least two units and two dates.

Each channel and control must vary within at least one fitted unit. A predictor that is constant within every unit cannot be estimated by FE and causes a pre-fit error. The target must also have non-zero within-unit variation.

This is not the same as adding geo controls to a pooled model. FE discards between-geo level variation from the likelihood for the shared slope coefficients.

Estimability screen

Before the main graph is created, Abacus evaluates media after the configured adstock and saturation transforms at a fixed-seed PyMC initial point. It saves:

  • 10_pre_diagnostics/fixed_effects_estimability.csv
  • 10_pre_diagnostics/fixed_effects_estimability.json

The report records the reference basis, within-variation share, VIF, condition number, rank, and the thresholds used. Its default policy is:

Check Default Action
Zero transformed within-unit variation exact numerical check Error
Rank-deficient transformed within design exact matrix-rank check Error
Within-variation share below 0.05 Warning
VIF above 20 Warning
Condition number at least 30 Warning

You may version a stricter or looser warning policy in the estimator block:

estimator:
  type: fe
  unit: geo
  estimability:
    within_variation_share_warning: 0.10
    max_vif_warning: 10
    condition_number_warning: 20

The zero-variation and rank checks remain errors. Do not suppress a warning by changing a threshold without recording why the underlying design remains fit for purpose.

The screen is a reference-design diagnostic. It cannot prove identification for every posterior draw because adstock and saturation parameters are estimated. A pass is necessary for the released graph, not sufficient evidence for a causal or decision claim.

Interpretation

For a media channel to be identified, it needs meaningful within-unit temporal variation after the configured transforms. National media that is identical in every geography can still vary over time, but it can be difficult to separate from common shocks. The initial FE contract does not add time fixed effects, so it does not claim to solve that problem.

FE removes time-invariant unit characteristics. It does not solve time-varying endogeneity, anticipation, simultaneous promotions, or measurement error. Use the preflight report, posterior diagnostics, predictive checks, prior sensitivity, and an explicit causal design before making attribution or budget decisions.

What comes next

RE and CRE are separate estimator contracts. CRE is released under its own transformed-summary and prediction boundary; RE remains unavailable.

For a direct comparison, see Choose an Estimator.