MMM Calibration Export#

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

Overview#

See also

Tutorials 29 (MMM Calibration with PyMC-Marketing Lift Tests) and 30 (MMM Calibration with Meridian ROI Priors) run both hand-offs end-to-end against the real frameworks - a lift-test calibration in PyMC-Marketing and an ROI-prior calibration in Meridian - including the with/without-calibration posterior comparison.

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 numbers in, or the pinned aggregation contract in - validated out. Reconciling an experiment’s estimate to a calibration input needs context diff-diff cannot always 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). The default route stays fully explicit: 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). Alternatively both exporters accept aggregation_result= - the pinned AggregationResult returned by results.aggregate('simple'), results.aggregate('group') (each with scale=), or results.aggregate('total') (already the total; no scale) - deriving effect = att * scale and se = se * scale per row; scale="auto" (reading the container’s own treated-observation count) is honored only for ImputationDiD and TwoStageDiD fits, and acknowledges assumptions the container cannot verify (additive-level outcome, unweighted fit, fully identified effects - see the docstrings). 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=None, sigma=None, aggregation_result=None, scale=None, 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).

Two input routes. Either the caller supplies the scoped effect explicitly - delta_y and sigma, the measured incremental outcome and its standard error already aggregated to the population and time window ONE target-MMM row represents - or passes aggregation_result= - the pinned container returned by results.aggregate('simple') / results.aggregate('group') (together with scale=, this function deriving delta_y = att * scale and sigma = se * scale per container row) or by results.aggregate('total') (a single already-scaled total row; NO scale accepted). Rescaling is performed ONLY under that explicit contract - reconciliation context the container cannot carry (the MMM’s row granularity, the outcome’s scale) remains the caller’s acknowledgement, see scale. Either way, 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, optional) – 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). Required together with sigma unless aggregation_result is given (the two routes are mutually exclusive).

  • sigma (float or sequence of float, optional) – Standard error of delta_y (finite, positive). Required together with delta_y unless aggregation_result is given.

  • aggregation_result (AggregationResult, optional) – The pinned container returned by results.aggregate('simple') (one experiment row), results.aggregate('group') (one row per cohort), or results.aggregate('total') (one already-scaled total row). Mutually exclusive with delta_y/sigma; requires scale for ‘simple’/’group’ containers and FORBIDS it for ‘total’. Rows are consumed in aggregation_result.to_dataframe() order - the order summary() prints - and every per-row sequence kwarg (scale, x, delta_x, dims, …) aligns to that order. Raw results objects and EventStudyResults are rejected; this function never calls aggregate() itself. On a bootstrapped fit, aggregate('simple') and, where supported, aggregate('total') relay the stored percentile SE, which is used as-is. Group-container cautions: (1) cohort rows come from ONE fit sharing controls and windows, yet each emitted lift row is scored by PyMC-Marketing as an independent observation with only its marginal sigma - the omitted cross-cohort covariance can misstate the joint evidence (overstating it when the net covariance is positive, as shared controls typically induce), and its direction cannot be determined from marginal SEs alone; (2) a scalar x or delta_x replicates to every cohort row, asserting the channel’s FULL baseline spend / spend change produced only that cohort’s delta_y - pass per-cohort values; (3) the emitted frame carries NO cohort-label column, so pass per-row dims to keep multi-cohort rows distinguishable.

  • scale (float, sequence of float, or "auto", optional) – Converts each container row’s per-observation ATT to the row’s total incremental outcome: delta_y = att * scale, sigma = se * scale. Required with a ‘simple’/’group’ aggregation_result; forbidden with a ‘total’ container, whose row is already the estimator-owned total (finite, positive; scalar or one value per row in to_dataframe() order; e.g. treated units x treated periods for an unweighted additive fit - an example, not a universal formula). scale="auto" derives scale from the container’s per-row n and is accepted ONLY for ImputationDiD and TwoStageDiD fits, whose n is the treated observations the ATT averages over. Passing "auto" acknowledges three assumptions the container cannot verify: the outcome is in additive levels (not log/rate/share); the fit is unweighted (on survey-weighted fits att is a weighted average while n is a raw count); and every treated observation’s effect is identified (ImputationDiD averages finite tau-hat only while n stays the raw count at both levels; TwoStageDiD’s ‘simple’ ATT support excludes rows with non-finite first-stage residuals while n reports the pre-filter count, though its ‘group’ counts are post-filter - the affected fits warn at fit time on the degenerate branch, and "auto" there overcounts). An att * scale (or se * scale) that overflows the float range surfaces via this function’s ordinary finiteness errors - sigma is validated before delta_y, so a row where both overflow reports sigma.

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

import numpy as np
import pandas as pd

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.)
rng = np.random.default_rng(42)
geos = [f"g{i}" for i in range(9)] + ["US-CA"]
panel = pd.DataFrame(
    {
        "geo": g,
        "week": w,
        "treated": int(g == "US-CA"),  # block treatment: constant per unit
        "revenue": 100.0 + 2.0 * w + rng.normal(0.0, 1.0)
        + (8.0 if g == "US-CA" and w >= 8 else 0.0),
    }
    for g in geos
    for w in range(12)
)
result = SyntheticDiD().fit(panel, outcome='revenue', treatment='treated',
                            unit='geo', time='week',
                            post_periods=list(range(8, 12)))

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)

Deriving totals from a fitted aggregation#

The primary route (3.10) is results.aggregate('total') - the estimator-owned total incremental outcome on CallawaySantAnna, EfficientDiD, ImputationDiD, and TwoStageDiD (panel non-survey fits): its single row is already C x overall over the estimator’s finite-masked complete-case support, so the exporter takes the container alone and rejects any scale. For overall-total exports this supersedes scale="auto"; "auto" remains the route for ImputationDiD/TwoStageDiD group-level (per-cohort) containers. Either way the outcome must be in additive levels (see the docstrings):

from diff_diff import ImputationDiD, to_pymc_marketing_lift_test
from diff_diff.prep import generate_staggered_data

data = generate_staggered_data(n_units=60, n_periods=6, seed=11)
res = ImputationDiD().fit(data, outcome='outcome', unit='unit',
                          time='period', first_treat='first_treat')

df_lift = to_pymc_marketing_lift_test(
    channel='tv',
    x=50_000.0,
    delta_x=20_000.0,
    aggregation_result=res.aggregate('total'),  # already the total; no scale
)
# delta_y == the total row's att, sigma == its se; the usual guards apply.
# Scaled-container alternatives: aggregation_result=res.aggregate('simple')
# (one overall row) or res.aggregate('group') (per-cohort rows) with
# scale='auto' - ImputationDiD's n IS the treated unit-periods there, at
# the cost of the documented raw-support caveat.

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=None, incremental_outcome_se=None, aggregation_result=None, scale=None, 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.

Two input routes. Either the caller supplies the scoped estimand explicitly - 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 - or passes aggregation_result=: with scale= for a ‘simple’/’group’ container (this function deriving incremental_outcome = att * scale and incremental_outcome_se = se * scale per container row), or a results.aggregate('total') container alone (a single already-scaled total row; NO scale accepted). Rescaling happens ONLY under that explicit contract; sign, estimand match, and population remain the caller’s responsibility.

Parameters:
  • incremental_outcome (float or sequence of float, optional) – 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). Required together with incremental_outcome_se unless aggregation_result is given (the two routes are mutually exclusive).

  • incremental_outcome_se (float or sequence of float, optional) – Standard error of incremental_outcome (finite, positive), computed for the caller’s aggregation. Required together with incremental_outcome unless aggregation_result is given.

  • aggregation_result (AggregationResult, optional) – The pinned container returned by results.aggregate('simple') (one experiment), results.aggregate('group') (one experiment per cohort, feeding the spend-weighted pooling below), or results.aggregate('total') (one already-scaled total experiment). Mutually exclusive with incremental_outcome/incremental_outcome_se; requires scale for ‘simple’/’group’ containers and FORBIDS it for ‘total’. Rows are consumed in aggregation_result.to_dataframe() order - the order summary() prints - and per-row sequence kwargs (scale, spend) align to that order. Raw results objects and EventStudyResults are rejected; this function never calls aggregate() itself. Group-container cautions: (1) cohort rows come from ONE fit sharing controls and windows, and the container carries only per-row SEs (the joint covariance’s off-diagonals are discarded upstream), so the independence pooling below can misstate the pooled uncertainty - it is anti-conservative when the net weighted covariance is positive (shared controls typically induce positive correlation), and the direction cannot be determined from marginal SEs alone; se_widening > 1 is a conservative heuristic, not an exact correction; (2) a scalar spend replicates per row, making total_spend = n_rows * spend and the pooled prior the arithmetic MEAN of cohort ROIs, sum(effect_i) / (n_rows * spend) - an n-fold understatement of channel ROI if the scalar was meant as the channel’s TOTAL spend; pass per-cohort spends.

  • scale (float, sequence of float, or "auto", optional) – Converts each container row’s per-observation ATT to that experiment’s total incremental outcome: incremental_outcome = att * scale, incremental_outcome_se = se * scale. Required with a ‘simple’/’group’ aggregation_result; forbidden with a ‘total’ container, whose row is already the estimator-owned total (finite, positive; scalar or one value per row in to_dataframe() order; e.g. treated units x treated periods for an unweighted additive fit - an example, not a universal formula). scale="auto" derives scale from the container’s per-row n and is accepted ONLY for ImputationDiD and TwoStageDiD fits, whose n is the treated observations the ATT averages over. Passing "auto" acknowledges three assumptions the container cannot verify: additive-level outcome (not log/rate/share), an unweighted fit (survey-weighted fits pair a weighted att with a raw-count n), and fully identified effects (ImputationDiD reports the raw treated-observation count at BOTH levels while averaging finite effects only; TwoStageDiD does so only at 'simple' - its 'group' counts are post-filter; the affected fits warn at fit time on the degenerate branch, and "auto" there overcounts). An overflowing att * scale surfaces via this function’s ordinary finiteness errors - incremental_outcome is validated before incremental_outcome_se, so a row where both overflow reports incremental_outcome.

  • 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
from diff_diff.prep import generate_did_data

panel = generate_did_data(n_units=60, n_periods=2, treatment_effect=5.0,
                          treatment_period=1, seed=7)
result = DifferenceInDifferences().fit(panel, outcome='outcome',
                                       treatment='treated', post='post')

# On the explicit route the caller aggregates the ATT to a total incremental
# outcome over the treated population and window (e.g. att x treated units x
# post periods for an unweighted additive fit) and supplies its SE; the
# aggregation-container route below derives the numbers instead.
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)

Scoping the prior to the experiment window#

to_code() requires the prior’s time scope. Build the boolean (n_media_times, n_media_channels) mask with meridian_calibration_mask() and pass it directly - the array is serialized into the generated snippet (a string expression or full_model_window=True also work). Note Meridian’s own guidance: its configure-model guide states that roi_calibration_period “is not generally recommended because calibrating the ROI of a specific time period does not necessarily improve estimation of the overall ROI” - prefer full_model_window=True when the experiment evidence reasonably transfers to the full window, and reserve the mask for evidence genuinely specific to a narrower period:

import pandas as pd

from diff_diff import meridian_calibration_mask, to_meridian_roi_prior

prior = to_meridian_roi_prior(
    incremental_outcome=180_000.0,
    incremental_outcome_se=45_000.0,
    spend=200_000.0,
)

# The MMM's own coordinates: time labels in model order, channels in
# InputData order. window=(start, end) is inclusive; pass a list for
# explicit (possibly non-contiguous) time labels instead.
media_times = pd.date_range('2023-09-04', periods=52, freq='W-MON')
mask = meridian_calibration_mask(
    media_times=media_times,
    media_channels=['search', 'tv'],
    channel='tv',
    window=('2024-01-15', '2024-03-04'),
)
print(prior.to_code(channel='tv', media_channels=['search', 'tv'],
                    roi_calibration_period=mask))
# The snippet rebuilds the same mask (all-True base; the experiment
# channel's column carries only the window - other channels keep ALL
# periods, Meridian's documented convention) and passes it to
# ModelSpec(roi_calibration_period=...). roi_m priors only: Meridian
# rejects the mask for mroi_m (use full_model_window=True there).

Deriving totals from a fitted aggregation#

Estimators outside the scale="auto" allowlist reach the exporter through results.aggregate('total') too - where supported, that container needs no scale at all. On the routings totals do not support (repeated-cross-section, declared survey_design=, divergent bare-cluster= masses), the caller supplies a numeric scale instead - a caller-defined estimand, needed because those containers’ n does not count treated unit-periods (CallawaySantAnna’s simple container, for example, counts treated and control units):

from diff_diff import CallawaySantAnna, to_meridian_roi_prior
from diff_diff.prep import generate_staggered_data

data = generate_staggered_data(n_units=60, n_periods=6, seed=11)
cs = CallawaySantAnna().fit(data, outcome='outcome', unit='unit',
                            time='period', first_treat='first_treat')

prior = to_meridian_roi_prior(
    aggregation_result=cs.aggregate('simple'),
    scale=132.0,        # caller-derived treated unit-periods for THEIR scoping
    spend=200_000.0,
)
# incremental_outcome == att * 132.0, and its SE == se * 132.0.

meridian_calibration_mask#

Build the boolean roi_calibration_period mask from the MMM’s own coordinates.

diff_diff.meridian_calibration_mask(*, media_times, media_channels, channel, window)[source]#

Build Meridian’s boolean roi_calibration_period mask for an experiment.

Returns a (len(media_times), len(media_channels)) bool array: the experiment channel’s column(s) are True exactly on the selected window, and every OTHER channel’s column is all-True - Meridian’s documented convention (“any media channels not specified … will utilize all available periods for ROI calibration”, configure-model guide); an all-False column would zero that channel’s aggregated calibration spend. Suitable to pass straight to Meridian’s spec.ModelSpec(roi_calibration_period=...), or to MeridianROIPrior.to_code(), which serializes it into the generated snippet. Valid for roi_m priors only: Meridian 1.7.0 rejects a non-None roi_calibration_period unless the media prior type is 'roi'.

Window convention: a 2-TUPLE is (start, end) INCLUSIVE bounds; any OTHER sequence (list, ndarray, Series, Index) is a set of explicit time labels. To select exactly two labels, pass a list [a, b], not a tuple.

Parameters:
  • media_times (list, tuple, ndarray, Series, or Index) – The MMM’s time coordinate labels, taken VERBATIM in model order (str dates, datetimes, periods, or ints; unique, no missing labels; these five container types are the accepted forms - materialize e.g. a range with list(...)). Meridian’s n_media_times can exceed len(data.time) when max_lag > 0 adds lagged leading periods, yet Google’s own configure-model example builds the mask with len(data.time) rows - this builder does not resolve that nuance: pass whichever coordinate list your ModelSpec expects, and the mask gets one row per entry. String labels order-compare lexicographically under a bounds window - right for zero-padded ISO dates ('2021-11-01'), wrong for '1/2/2021'-style formats (use explicit labels or datetime labels there).

  • media_channels (list, tuple, ndarray, Series, or Index of str) – Channel names in the Meridian InputData media-channel order (the same contract as to_code(media_channels=)); unique, non-empty. The mask’s columns are positioned by this order - keep the SAME list, in the same order, for the builder and for to_code.

  • channel (str or sequence of str) – The experiment channel(s) whose columns carry the window - here a sequence means “the set of mask columns to mark for THIS experiment” (unlike to_pymc_marketing_lift_test(channel=...), where a sequence is one channel per experiment row; the builder has no per-row axis). Every name must be in media_channels.

  • window (tuple or sequence) – Either (start, end) inclusive bounds - selected by order comparison against media_times, with pd.to_datetime coercion of the values when media_times is datetime-like (timezone mismatches fail closed in both directions) - or a sequence of explicit time labels, every one of which must be present in media_times. Must select at least one time. Selection is by label VALUE at its position, so an unsorted media_times is well-defined.

Returns:

Boolean, shape (len(media_times), len(media_channels)).

Return type:

np.ndarray

Raises:
  • TypeError – On wrong-typed inputs (string/Mapping/scalar/MultiIndex/non-1-D-array containers, a scalar or Mapping window, an array-valued window bound).

  • ValueError – On empty inputs, duplicate or missing labels, unknown channels, malformed or empty-selection windows, and unorderable or timezone- mismatched bounds.

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, execution-validated on 1.8.0).

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. Three routes:

  • roi_calibration_period=<boolean numpy array> - the (n_media_times, n_media_channels) mask, typically built by meridian_calibration_mask(). The array is serialized into the snippet as a short np.ones + per-column-group assignment prelude. Boolean dtype, or numeric containing only 0/1 (Google’s own docs build the mask with float zeros), is accepted and cast to bool; masked arrays are rejected (fill or drop the mask explicitly). An all-False mask is rejected, and so is ANY entirely-False column: Meridian aggregates each channel’s calibration spend through its mask column, so an all-False column zeroes it - channels without an experiment must use ALL periods (Google’s documented convention; the builder sets non-experiment channels all-True). Only roi_m priors can be time-scoped: Meridian 1.7.0 rejects a non-None roi_calibration_period unless the media prior type is 'roi', so parameter="mroi_m" priors must use full_model_window=True (applies to the expression-string route too). Two things this method cannot verify and the caller owns: the ROW count (no time coordinate is passed here - the builder guarantees consistency when its media_times matches the model’s coordinates; hand-built arrays are the caller’s responsibility) and the column ORDER/identity (the mask carries no channel labels - the media_channels given to the builder and to this method must be the same list in the same order; only the count is machine-checked).

  • roi_calibration_period="<expression>" - a Python expression string interpolated verbatim into the snippet (the pre-existing route).

  • full_model_window=True - acknowledges that the experiment window and the MMM window coincide. Note Meridian’s own guidance: the configure-model guide states the use of roi_calibration_period “is not generally recommended because calibrating the ROI of a specific time period does not necessarily improve estimation of the overall ROI” - prefer full_model_window=True when the experiment evidence reasonably transfers to the full window, and reserve the mask for evidence genuinely specific to a narrower period.

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:
Return type:

str

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

None

References#