MMM Calibration Export#

Assemble Marketing Mix Model (MMM) calibration inputs from experiment results.

Overview#

MMM practitioners calibrate their models against experimental evidence. The two dominant Python MMM frameworks consume that evidence in different shapes:

  1. PyMC-Marketing (and prophetverse, which mirrors the same schema) ingests a lift-test DataFrame - columns channel, optional model dims (e.g. geo), x, delta_x, delta_y, sigma - via MMM.add_lift_test_measurements.

  2. Google Meridian calibrates through lognormal priors: roi_m for the return on a channel’s full spend (a zero-spend / full-holdout estimand) and mroi_m for a marginal return. The point estimate maps to the prior mean and its standard error to the prior standard deviation, converted to lognormal (mu, sigma) with Google’s closed form.

Explicit in, validated out. These functions do not rescale a result’s headline ATT. Reconciling an experiment’s estimate to a calibration input needs context diff-diff cannot see - the target MMM’s row granularity (per-geo vs national), its time window, and the outcome’s scale (additive levels vs a log/rate/share) - so the caller supplies the already-scoped incremental outcome and its standard error (the numbers read off a fitted result’s summary(), aggregated to the population and window one MMM row represents). diff-diff does only what it can verify: assemble the exact schema, enforce each consumer’s guards, convert to the lognormal parameterization (parity with Google’s closed form), pool, and emit snippets. It is pure numpy/pandas and imports no MMM package.

to_pymc_marketing_lift_test#

Assemble a lift-test DataFrame for pymc_marketing.mmm.MMM.add_lift_test_measurements.

diff_diff.to_pymc_marketing_lift_test(*, channel, x, delta_x, delta_y, sigma, dims=None, on_wrong_sign='raise')[source]#

Assemble a PyMC-Marketing lift-test DataFrame from scoped experiment results.

Produces one row per experiment with columns channel, any dims columns, x, delta_x, delta_y, sigma - the exact schema consumed by pymc_marketing.mmm.MMM.add_lift_test_measurements (prophetverse’s lift-test API accepts the same shape). All values stay in original data units (PyMC-Marketing rescales internally).

The caller supplies the scoped effect. delta_y and sigma are the measured incremental outcome and its standard error, already aggregated to the population and time window ONE target-MMM row represents. This function does not rescale an ATT, because the reconciliation needs the MMM’s row granularity and outcome scale, which it cannot see. Read the effect and SE off your fitted result’s summary() and scope them yourself: PyMC-Marketing scores one row’s delta_y against saturation(x + delta_x) - saturation(x), so x, delta_x, delta_y, and sigma must all describe the SAME observation (same channel, same population, same period span, additive-level outcome).

Parameters:
  • channel (str or sequence of str) – MMM channel name(s); must match the target model’s channel_columns.

  • x (float or sequence of float) – Baseline channel spend for the row’s observation, in original spend units.

  • delta_x (float or sequence of float) – Spend change during the experiment (nonzero; negative for go-dark/holdout tests, with x + delta_x >= 0), in the same units as x.

  • delta_y (float or sequence of float) – Measured incremental outcome for the SAME observation as x/delta_x, in original outcome units. Finite; must be nonzero and share delta_x’s sign (see on_wrong_sign).

  • sigma (float or sequence of float) – Standard error of delta_y (finite, positive).

  • dims (mapping or sequence of mappings, optional) – Extra model-dimension columns, e.g. {"geo": "US-CA"} for a geo-level MMM built with MMM(dims=("geo",)). Values must match the target model’s coordinate values exactly and identify the population delta_y describes (do not label a multi-geo average with one specific geo). All rows must share one key set; column order follows the first mapping.

  • on_wrong_sign ({"raise", "drop", "keep"}, default "raise") – Policy for rows PyMC-Marketing cannot use: sign(delta_y) contradicting sign(delta_x) (rejected upstream with NonMonotonicError), or delta_y == 0 (degenerate for its strictly-positive Gamma lift likelihood, which its own monotonicity check does not catch). "raise" (default) errors with guidance; "drop" warns and removes such rows (raises if that would empty the frame); "keep" warns and emits them anyway (for non-PyMC consumers - the frame is not valid PyMC-Marketing input).

Returns:

Columns [channel, *dims, x, delta_x, delta_y, sigma], one row per experiment.

Return type:

pd.DataFrame

Raises:

ValueError – On invalid inputs (non-positive sigma, non-finite values, delta_x == 0, x + delta_x < 0, broadcasting length mismatches, empty inputs, heterogeneous dims key sets) or wrong-signed/zero rows under the default policy.

Example#

from diff_diff import SyntheticDiD, to_pymc_marketing_lift_test

# Single-treated-geo experiment (US-CA): TV spend raised there vs control
# geos. With exactly ONE treated geo, the SDID ATT is that geo's per-week
# lift, so labelling the row with its coordinate is sound. (For MULTIPLE
# treated geos the pooled ATT is an average - do not assign it to one geo;
# export each geo's own effect, or omit the geo dim and match aggregate
# spend to an aggregate lift.)
result = SyntheticDiD().fit(panel, outcome='revenue', treatment='treated',
                            unit='geo', time='week')

df_lift = to_pymc_marketing_lift_test(
    channel='tv',
    x=50_000.0,           # baseline weekly TV spend in US-CA
    delta_x=20_000.0,     # spend increase during the test
    delta_y=result.att,   # US-CA's per-week lift (one treated geo)
    sigma=result.se,      # its standard error
    dims={'geo': 'US-CA'},
)
# -> columns [channel, geo, x, delta_x, delta_y, sigma], ready for
#    MMM.add_lift_test_measurements(df_lift)

to_meridian_roi_prior#

Build a Meridian lognormal roi_m/mroi_m prior from scoped experiment result(s).

diff_diff.to_meridian_roi_prior(*, incremental_outcome, incremental_outcome_se, spend, parameter='roi_m', se_widening=1.0)[source]#

Build a Meridian lognormal ROI/mROI prior from scoped experiment result(s).

Per experiment roi = incremental_outcome / spend with standard deviation incremental_outcome_se / spend * se_widening. Multiple experiments for the same channel are pooled with spend weights - the spend-weighted average ROI the Meridian FAQ suggests for multiple experiments (citing section 3.4 of Google’s MMM calibration whitepaper): roi_mean = sum(w_i * roi_i) with w_i = spend_i / sum(spend). The pooled roi_sd = sqrt(sum((w_i * sd_i)^2)) is this library’s uncertainty propagation for that average (treats experiments as independent - widen via se_widening when experiments share controls or windows). The pooled mean/sd map to lognormal (mu, sigma) via Google’s closed form.

The caller supplies the scoped estimand. incremental_outcome is the total incremental outcome the experiment measured, matching the estimand of the target prior: for parameter="roi_m" that is the outcome attributable to the channel’s spend against a zero-spend counterfactual (a full holdout), divided here by spend = the channel’s total spend over the window; for parameter="mroi_m" it is the marginal outcome of the spend change, divided by that spend change. Sign, aggregation, and population are the caller’s responsibility - diff-diff does not rescale a headline ATT.

Parameters:
  • incremental_outcome (float or sequence of float) – Total incremental outcome per experiment (the estimand matching parameter), in the same currency/units as the MMM. Must be finite; the pooled ROI must be positive (lognormal support).

  • incremental_outcome_se (float or sequence of float) – Standard error of incremental_outcome (finite, positive), computed for the caller’s aggregation.

  • spend (float or sequence of float) – Spend the incremental outcome is divided by (finite, positive): the channel’s total spend over the window for roi_m, or the spend change for mroi_m.

  • parameter ({"roi_m", "mroi_m"}, default "roi_m") – Which Meridian prior the estimate informs. roi_m is the return on the channel’s full spend (a full-holdout / zero-spend estimand); mroi_m is the marginal return of a spend change. Under saturation these differ, so the caller declares which their incremental_outcome measures; the returned prior and .to_code() target that parameter with its own Meridian default for non-experiment channels.

  • se_widening (float, default 1.0) – Multiplier on each experiment’s ROI standard deviation (finite, positive). Values above 1 encode skepticism about experiment-to-MMM transferability.

Returns:

Pooled roi_mean/roi_sd, the lognormal mu/sigma, the target parameter, per-experiment detail, plus .to_dict() and the channel-scoped .to_code() snippet helper.

Return type:

MeridianROIPrior

Raises:

ValueError – On an invalid parameter, non-positive spend/se/se_widening, non-finite inputs, broadcasting mismatches, empty inputs, a non-positive pooled ROI mean (lognormal support), or non-finite scaled/pooled results.

Example#

from diff_diff import DifferenceInDifferences, to_meridian_roi_prior

result = DifferenceInDifferences().fit(panel, outcome='revenue',
                                       treatment='treated', time='post')

# Caller aggregates the ATT to a total incremental outcome over the treated
# population and window (e.g. att x treated units x post periods) and supplies
# its SE - diff-diff does not rescale the headline effect.
prior = to_meridian_roi_prior(
    incremental_outcome=180_000.0,     # total incremental revenue
    incremental_outcome_se=45_000.0,   # its standard error
    spend=200_000.0,                   # total channel spend over the window
    parameter='roi_m',                 # or 'mroi_m' for a marginal experiment
    se_widening=1.5,                   # optional transferability skepticism
)
print(prior.roi_mean, prior.roi_sd)

# Channel- and time-scoped snippet: roi_m is per-channel, and the prior was
# estimated on the experiment window.
print(prior.to_code(channel='tv', media_channels=['search', 'tv'],
                    roi_calibration_period='experiment_window_mask'))

MeridianROIPrior#

Result container for the Meridian exporter.

class diff_diff.MeridianROIPrior[source]

Bases: object

Lognormal prior parameters for Meridian’s roi_m/mroi_m calibration.

mu/sigma reproduce meridian.model.prior_distribution. lognormal_dist_from_mean_std(roi_mean, roi_sd) exactly (closed form; no Meridian dependency). parameter records which Meridian prior the caller is informing ("roi_m" or "mroi_m"). per_experiment records each pooled experiment’s ROI, widened sd, spend, and spend weight.

roi_mean: float
roi_sd: float
mu: float
sigma: float
parameter: str = 'roi_m'
per_experiment: Tuple[ExperimentROI, ...] = ()
to_dict()[source]
Return type:

Dict[str, Any]

to_code(*, channel=None, media_channels=None, single_channel=False, roi_calibration_period=None, full_model_window=False)[source]

Ready-to-paste Meridian snippet (channel- and time-scoped; 1.7.0 pinned).

Meridian’s roi_m/mroi_m prior has batch shape n_media_channels and a scalar distribution broadcasts to EVERY media channel - a TV experiment’s prior must not silently calibrate search, social, etc. Channel scope is therefore always explicit:

  • to_code(channel="tv", media_channels=["search", "tv"]) emits vector mu/sigma in exactly the media_channels order (which must match the Meridian InputData channel order); the experiment channel carries this prior and every other channel keeps Meridian’s default.

  • to_code(single_channel=True) emits the scalar snippet for single-channel models, marked as such in the generated code.

  • Calling with neither raises ValueError.

The prior’s TIME scope is also required: Meridian’s default roi_calibration_period=None applies the prior over all model times, but the prior was estimated on the experiment window. Pass roi_calibration_period="<expression>" (the boolean (n_media_times, n_media_channels) mask for your window) or full_model_window=True to acknowledge that the two coincide.

Snippets use the TensorFlow substrate of TensorFlow Probability; JAX-backed Meridian users should swap the import for tensorflow_probability.substrates.jax (noted in the generated code).

Parameters:
  • channel (str | None)

  • media_channels (Sequence[str] | None)

  • single_channel (bool)

  • roi_calibration_period (str | None)

  • full_model_window (bool)

Return type:

str

__init__(roi_mean, roi_sd, mu, sigma, parameter='roi_m', per_experiment=())
Parameters:
Return type:

None

References#