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:
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- viaMMM.add_lift_test_measurements.Google Meridian calibrates through lognormal priors:
roi_mfor the return on a channel’s full spend (a zero-spend / full-holdout estimand) andmroi_mfor 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, anydimscolumns,x,delta_x,delta_y,sigma- the exact schema consumed bypymc_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_yandsigma, the measured incremental outcome and its standard error already aggregated to the population and time window ONE target-MMM row represents - or passesaggregation_result=- the pinned container returned byresults.aggregate('simple')/results.aggregate('group')(together withscale=, this function derivingdelta_y = att * scaleandsigma = se * scaleper container row) or byresults.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, seescale. Either way, PyMC-Marketing scores one row’sdelta_yagainstsaturation(x + delta_x) - saturation(x), sox,delta_x,delta_y, andsigmamust 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 asx.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 sharedelta_x’s sign (seeon_wrong_sign). Required together withsigmaunlessaggregation_resultis given (the two routes are mutually exclusive).sigma (float or sequence of float, optional) – Standard error of
delta_y(finite, positive). Required together withdelta_yunlessaggregation_resultis given.aggregation_result (AggregationResult, optional) – The pinned container returned by
results.aggregate('simple')(one experiment row),results.aggregate('group')(one row per cohort), orresults.aggregate('total')(one already-scaled total row). Mutually exclusive withdelta_y/sigma; requiresscalefor ‘simple’/’group’ containers and FORBIDS it for ‘total’. Rows are consumed inaggregation_result.to_dataframe()order - the ordersummary()prints - and every per-row sequence kwarg (scale,x,delta_x,dims, …) aligns to that order. Raw results objects andEventStudyResultsare rejected; this function never callsaggregate()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 marginalsigma- 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 scalarxordelta_xreplicates to every cohort row, asserting the channel’s FULL baseline spend / spend change produced only that cohort’sdelta_y- pass per-cohort values; (3) the emitted frame carries NO cohort-label column, so pass per-rowdimsto 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 into_dataframe()order; e.g. treated units x treated periods for an unweighted additive fit - an example, not a universal formula).scale="auto"derivesscalefrom the container’s per-rownand is accepted ONLY for ImputationDiD and TwoStageDiD fits, whosenis 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 fitsattis a weighted average whilenis a raw count); and every treated observation’s effect is identified (ImputationDiD averages finite tau-hat only whilenstays the raw count at both levels; TwoStageDiD’s ‘simple’ ATT support excludes rows with non-finite first-stage residuals whilenreports 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). Anatt * scale(orse * scale) that overflows the float range surfaces via this function’s ordinary finiteness errors -sigmais validated beforedelta_y, so a row where both overflow reportssigma.dims (mapping or sequence of mappings, optional) – Extra model-dimension columns, e.g.
{"geo": "US-CA"}for a geo-level MMM built withMMM(dims=("geo",)). Values must match the target model’s coordinate values exactly and identify the populationdelta_ydescribes (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)contradictingsign(delta_x)(rejected upstream withNonMonotonicError), ordelta_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, heterogeneousdimskey 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 / spendwith standard deviationincremental_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)withw_i = spend_i / sum(spend). The pooledroi_sd = sqrt(sum((w_i * sd_i)^2))is this library’s uncertainty propagation for that average (treats experiments as independent - widen viase_wideningwhen 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_outcomeis the total incremental outcome the experiment measured, matching the estimand of the target prior: forparameter="roi_m"that is the outcome attributable to the channel’s spend against a zero-spend counterfactual (a full holdout), divided here byspend= the channel’s total spend over the window; forparameter="mroi_m"it is the marginal outcome of the spend change, divided by that spend change - or passesaggregation_result=: withscale=for a ‘simple’/’group’ container (this function derivingincremental_outcome = att * scaleandincremental_outcome_se = se * scaleper container row), or aresults.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 withincremental_outcome_seunlessaggregation_resultis 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 withincremental_outcomeunlessaggregation_resultis 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), orresults.aggregate('total')(one already-scaled total experiment). Mutually exclusive withincremental_outcome/incremental_outcome_se; requiresscalefor ‘simple’/’group’ containers and FORBIDS it for ‘total’. Rows are consumed inaggregation_result.to_dataframe()order - the ordersummary()prints - and per-row sequence kwargs (scale,spend) align to that order. Raw results objects andEventStudyResultsare rejected; this function never callsaggregate()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 > 1is a conservative heuristic, not an exact correction; (2) a scalarspendreplicates per row, makingtotal_spend = n_rows * spendand 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 into_dataframe()order; e.g. treated units x treated periods for an unweighted additive fit - an example, not a universal formula).scale="auto"derivesscalefrom the container’s per-rownand is accepted ONLY for ImputationDiD and TwoStageDiD fits, whosenis 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 weightedattwith a raw-countn), 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 overflowingatt * scalesurfaces via this function’s ordinary finiteness errors -incremental_outcomeis validated beforeincremental_outcome_se, so a row where both overflow reportsincremental_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 formroi_m.parameter ({"roi_m", "mroi_m"}, default "roi_m") – Which Meridian prior the estimate informs.
roi_mis the return on the channel’s full spend (a full-holdout / zero-spend estimand);mroi_mis the marginal return of a spend change. Under saturation these differ, so the caller declares which theirincremental_outcomemeasures; 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 lognormalmu/sigma, the targetparameter, per-experiment detail, plus.to_dict()and the channel-scoped.to_code()snippet helper.- Return type:
- Raises:
ValueError – On an invalid
parameter, non-positivespend/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_periodmask 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’sspec.ModelSpec(roi_calibration_period=...), or toMeridianROIPrior.to_code(), which serializes it into the generated snippet. Valid forroi_mpriors only: Meridian 1.7.0 rejects a non-Noneroi_calibration_periodunless 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
rangewithlist(...)). Meridian’sn_media_timescan exceedlen(data.time)whenmax_lag > 0adds lagged leading periods, yet Google’s own configure-model example builds the mask withlen(data.time)rows - this builder does not resolve that nuance: pass whichever coordinate list yourModelSpecexpects, 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
InputDatamedia-channel order (the same contract asto_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 forto_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 inmedia_channels.window (tuple or sequence) – Either
(start, end)inclusive bounds - selected by order comparison againstmedia_times, withpd.to_datetimecoercion of the values whenmedia_timesis datetime-like (timezone mismatches fail closed in both directions) - or a sequence of explicit time labels, every one of which must be present inmedia_times. Must select at least one time. Selection is by label VALUE at its position, so an unsortedmedia_timesis 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:
objectLognormal prior parameters for Meridian’s
roi_m/mroi_mcalibration.mu/sigmareproducemeridian.model.prior_distribution. lognormal_dist_from_mean_std(roi_mean, roi_sd)exactly (closed form; no Meridian dependency).parameterrecords which Meridian prior the caller is informing ("roi_m"or"mroi_m").per_experimentrecords 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_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_mprior has batch shapen_media_channelsand 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 vectormu/sigmain exactly themedia_channelsorder (which must match the MeridianInputDatachannel 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=Noneapplies 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 bymeridian_calibration_mask(). The array is serialized into the snippet as a shortnp.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). Onlyroi_mpriors can be time-scoped: Meridian 1.7.0 rejects a non-Noneroi_calibration_periodunless the media prior type is'roi', soparameter="mroi_m"priors must usefull_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 itsmedia_timesmatches the model’s coordinates; hand-built arrays are the caller’s responsibility) and the column ORDER/identity (the mask carries no channel labels - themedia_channelsgiven 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 ofroi_calibration_period“is not generally recommended because calibrating the ROI of a specific time period does not necessarily improve estimation of the overall ROI” - preferfull_model_window=Truewhen 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).
References#
PyMC-Marketing lift-test calibration: https://www.pymc-marketing.io/en/stable/notebooks/mmm/mmm_lift_test.html
Google Meridian, “Set custom prior distributions using past experiments”: https://developers.google.com/meridian/docs/advanced-modeling/set-custom-priors-past-experiments
Google Meridian, ROI/mROI/contribution parameterizations: https://developers.google.com/meridian/docs/advanced-modeling/roi-mroi-contribution-parameterizations
Google Meridian, “Set the ROI calibration period” (the
roi_calibration_periodmask shape/semantics contract): https://developers.google.com/meridian/docs/user-guide/configure-modelZhou, G., Choe, Y., & Hetrakul, C. (2023). Calibrated MMM better predicts true ROAS. Meta Marketing Science.