Skip to content

feat: MMM calibration export (diff_diff.mmm) - PyMC-Marketing lift tests + Meridian ROI priors#699

Merged
igerber merged 1 commit into
mainfrom
mmm
Jul 19, 2026
Merged

feat: MMM calibration export (diff_diff.mmm) - PyMC-Marketing lift tests + Meridian ROI priors#699
igerber merged 1 commit into
mainfrom
mmm

Conversation

@igerber

@igerber igerber commented Jul 18, 2026

Copy link
Copy Markdown
Owner

Summary

  • New interop module diff_diff/mmm.py converting DiD experiment results into MMM calibration inputs, with zero MMM-package dependency (pure numpy/pandas; emits plain DataFrames/dataclasses)
  • to_pymc_marketing_lift_test(): emits the lift-test DataFrame consumed by PyMC-Marketing's MMM.add_lift_test_measurements (channel/dims/x/delta_x/delta_y/sigma), with an explicit four-column unit contract (aggregate="mean"|"sum"), n_units= geo-to-national scaling, on_wrong_sign={"raise","drop","keep"} policy mirroring the upstream monotonicity check, an x + delta_x >= 0 post-test-spend guard, and reserved-column dims validation; schema is prophetverse-compatible
  • to_meridian_roi_prior(): emits Google Meridian lognormal ROI prior parameters (MeridianROIPrior) via Google's closed form, with spend-weighted multi-experiment pooling (per the Meridian FAQ recommendation, citing sec 3.4 of Google's MMM calibration whitepaper), an se_widening= transferability knob, and a channel-scoped .to_code() snippet helper (roi_m is per-channel in Meridian, so vector priors require the model channel order; scalar output needs explicit single_channel=True)
  • Duck-typed against the flat att/se surface every headline results class exposes; treated counts are never inferred from result metadata (n_treated is a row count on several classes); n_units/n_periods must be positive integers
  • Docs: new REGISTRY.md "MMM Calibration Export (interop)" section, docs/api/mmm.rst, llms.txt + llms-full.txt sections, README one-liner, references.rst entries, doc-deps.yaml entry, CHANGELOG, TODO.md follow-up rows for documented deferrals (Meridian roi_calibration_period mask, event-study temporal lift export, Robyn, GeoX)

Methodology references (required if estimator / math changes)

  • Method name(s): MMM lift-test calibration export (PyMC-Marketing schema); Meridian lognormal ROI prior construction with spend-weighted experiment pooling. Interop only - no estimator or inference change.
  • Paper / source link(s): PyMC-Marketing lift-test calibration docs (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) and meridian/model/prior_distribution.py at 1.7.0 (lognormal closed form + per-channel roi_m batch shape + default LogNormal(0.2, 0.9)); Meridian FAQ (spend-weighted average ROI for multiple experiments, citing sec 3.4 of Google's MMM calibration whitepaper); Zhou, Choe & Hetrakul (2023), Meta Marketing Science (calibration motivation)
  • Any intentional deviations from the source (and why): Pooled roi_sd = sqrt(sum((w_i * sd_i)^2)) treats experiments as independent - this uncertainty propagation is this library's policy (the FAQ prescribes only the spend-weighted mean); documented with a **Note:** in REGISTRY.md, with se_widening= as the correlated-experiments mitigation.

Validation

  • Tests added/updated: tests/test_mmm.py (new, 51 behavioral tests): exact schema emission, hand-computed scaling for mean/sum/n_units, wrong-sign policies incl. drop-all-raises, go-dark boundary (x + delta_x == 0 passes, below raises), NaN/zero-SE rejection, broadcasting/empty-sequence/reserved-dims errors, scipy lognormal moment round-trip (1e-12), spend-weighted pooling math, channel-scope regression for .to_code() (experiment prior lands only in its channel slot), units-not-rows pin (no silent n_treated fallback), and cross-estimator duck-typing contract (DifferenceInDifferences / SyntheticDiD / MultiPeriodDiD / CallawaySantAnna)
  • Backtest / simulation / notebook evidence (if applicable): End-to-end smoke against real pymc-marketing 0.19.4 in an isolated venv - the emitted frame was accepted by MMM.add_lift_test_measurements into the model graph, and a wrong-signed row reproduced upstream NonMonotonicError; mu/sigma verified bit-identical to Meridian's lognormal_dist_from_mean_std over 200 random draws. Tutorial notebook follows in PR-B.

Security / privacy

  • Confirm no secrets/PII in this PR: Yes

🤖 Generated with Claude Code

https://claude.ai/code/session_014wAAYSVo1Yf3XMcxNMrqsj

@github-actions

Copy link
Copy Markdown

Overall Assessment

⚠️ Needs changes — one unmitigated P1 anti-pattern in the new test helper. I did not find a methodology mismatch in the new MMM exporters.

Executive Summary

  • Affected methods: to_pymc_marketing_lift_test() and to_meridian_roi_prior() in diff_diff/mmm.py.
  • Methodology matches the new REGISTRY.md entry and the cited PyMC-Marketing / Meridian contracts: lift-test schema, sign monotonicity, lognormal ROI prior construction, and explicit unit/spend inputs are consistent.
  • Documented deviations are correctly treated as non-blocking: independent-experiment pooled SD is labeled with a **Note:**, and deferred Meridian roi_calibration_period / temporal export work is tracked in TODO.md.
  • Blocking issue: tests/test_mmm.py::make_result() computes t_stat, p_value, and CI inline instead of using safe_inference(), violating the project’s known anti-pattern rule.
  • I could not run tests in this sandbox because pytest and numpy are not installed.

Methodology

P3 — Documented Meridian pooled-SD policy

Impact: to_meridian_roi_prior() pools experiment means with spend weights and uses sqrt(sum((w_i * sd_i)^2)) for pooled SD. Meridian’s FAQ documents spend-weighted average ROI, while the SD propagation is a library policy. This is explicitly documented in REGISTRY.md with a **Note:**, so it is not a defect. See diff_diff/mmm.py:L580-L596 and docs/methodology/REGISTRY.md:L5620-L5626.

Concrete fix: None required.

No unmitigated methodology findings

PyMC-Marketing’s lift-test API consumes channel, x, delta_x, delta_y, and sigma, evaluates saturation(x + delta_x) - saturation(x), and enforces monotonic sign consistency; the exporter mirrors that contract with reserved-column validation, sign policy, and x + delta_x >= 0 guard. Meridian documents ROI priors as lognormal distributions from point estimate / standard error and defines roi_m defaults / scalar broadcasting; the exporter’s lognormal conversion and channel-scoped .to_code() behavior match that source contract. (pymc-marketing.io)

Code Quality

P1 — Inline inference computation in new test helper

Location: tests/test_mmm.py:L29-L36

Impact: The new helper constructs DiDResults with t_stat=att / se, a hard-coded p_value, and a manually computed CI. The review rules explicitly classify new inline inference computation as P1 because it bypasses the project-wide safe_inference() all-or-nothing NaN contract. This is test code, but it still creates result objects with statistical fields that can become inconsistent as tests evolve.

Concrete fix: import safe_inference and build the helper fields from it:

from diff_diff.utils import safe_inference

t_stat, p_value, conf_int = safe_inference(att, se)
return DiDResults(
    att=att,
    se=se,
    t_stat=t_stat,
    p_value=p_value,
    conf_int=conf_int,
    ...
)

Performance

No findings. The exporters are small linear passes over experiment rows; no obvious performance issue is introduced.

Maintainability

No blocking findings. The new module is cohesive, has explicit validation helpers, and keeps MMM package dependencies out of runtime imports.

Tech Debt

P3 — Deferred MMM interop items are properly tracked

Impact: Missing Meridian roi_calibration_period mask building, temporal lift export, Robyn export, and GeoX export are documented in TODO.md, so they are not blockers under the deferred-work policy. See TODO.md:L52-L53 and TODO.md:L123-L125.

Concrete fix: None required for this PR.

Security

No findings. I did not see secrets, credentials, or unsafe external execution paths in the changed code.

Documentation/Tests

P3 — Meridian .to_code() snippet is TensorFlow-flavored only

Location: diff_diff/mmm.py:L59-L85

Impact: Meridian docs note that JAX users need tfp.substrates.jax imports. The generated snippet always imports tensorflow_probability as tfp, which is fine as a TensorFlow snippet but may be less helpful for JAX-backed Meridian users. (developers.google.com)

Concrete fix: Optional: add a backend="tensorflow"|"jax" argument to .to_code() or document that the snippet is TensorFlow-style and must be adapted for JAX.

Test execution

I attempted to run PYTHONDONTWRITEBYTECODE=1 pytest -q tests/test_mmm.py -p no:cacheprovider, but pytest is not installed. A simple import smoke also failed because numpy is not installed in this sandbox.

Path to Approval

  1. Replace the inline inference fields in tests/test_mmm.py::make_result() with safe_inference(att, se).
  2. Re-run tests/test_mmm.py in an environment with the project test dependencies installed.

@github-actions

Copy link
Copy Markdown

🔁 AI review rerun (requested by @igerber)

Head SHA: ad675abfab638760a6faeb674f1becd61ead2702


Overall Assessment

Looks good — no unmitigated P0/P1 findings.

Executive Summary

  • Re-review finding resolved: tests/test_mmm.py::make_result() now uses safe_inference() for t_stat, p_value, and CI.
  • Affected methods: to_pymc_marketing_lift_test() and to_meridian_roi_prior() in diff_diff/mmm.py.
  • Methodology matches the new Registry entry and the cited PyMC-Marketing / Meridian contracts for lift-test shape, saturation-difference semantics, lognormal ROI priors, and explicit unit/spend inputs. (pymc-marketing.io)
  • The Meridian pooled-SD policy is a documented library deviation, so it is P3 informational, not a defect.
  • I could not run tests here because pytest and numpy are not installed in the sandbox.

Methodology

P3 — Documented Meridian pooled-SD policy

Severity: P3 informational

Impact: to_meridian_roi_prior() uses spend-weighted ROI pooling and sqrt(sum((w_i * sd_i)^2)) for pooled SD. The Meridian FAQ recommends spend-weighted average ROI and notes further weighting may be warranted when SEs differ; the PR’s SD propagation is explicitly documented as a library policy in the Registry. See diff_diff/mmm.py:L588-L604, docs/methodology/REGISTRY.md:L5620-L5626, and TODO.md:L52-L53. (developers.google.com)

Concrete fix: None required.

No unmitigated methodology findings

PyMC-Marketing documents a lift-test frame with channel, x, delta_x, delta_y, and sigma, and scores the empirical lift against saturation(x + delta_x) - saturation(x). The exporter emits that schema and applies the Registry’s scaling/sign/empty-output guards. Meridian documents constructing ROI priors from experiment point estimates and standard errors via lognormal_dist_from_mean_std, and its default-prior docs confirm scalar prior broadcasting plus default roi_m = LogNormal(0.2, 0.9); .to_code() avoids accidental scalar broadcast by requiring explicit channel scope. (pymc-marketing.io)

Code Quality

No findings.

Re-review note: the previous P1 inline inference anti-pattern is resolved at tests/test_mmm.py:L27-L38.

Performance

No findings.

The exporters are linear over the result rows and do not introduce heavy runtime dependencies.

Maintainability

No findings.

The new module is cohesive and keeps PyMC-Marketing/Meridian as output contracts rather than runtime dependencies.

Tech Debt

P3 — Deferred MMM interop follow-ups are tracked

Severity: P3 informational

Impact: Meridian roi_calibration_period, event-study temporal lift export, Robyn export, and GeoX export are deferred but tracked in TODO.md, so they are not blockers under the review policy. See TODO.md:L52-L53 and TODO.md:L123-L125.

Concrete fix: None required for this PR.

Security

No findings.

I did not see secrets, credential handling, unsafe subprocess execution, or network calls in the changed code.

Documentation/Tests

No blocking findings.

The new tests cover schema emission, scaling, wrong-sign policy, empty/drop-all handling, NaN/SE rejection, count handling, lognormal moment round-trip, pooling, channel-scoped code generation, and cross-estimator duck typing.

Test execution attempted:
PYTHONDONTWRITEBYTECODE=1 pytest -q tests/test_mmm.py -p no:cacheprovider

Result: not run because pytest is not installed. A Python import smoke also failed because numpy is not installed.

@github-actions

Copy link
Copy Markdown

🔁 AI review rerun (requested by @igerber)

Head SHA: ce5b85e49a7c5a02646d4b1bee80b9c86e94f357


Overall Assessment

Blocker — one newly identified P0 silent scaling error remains.

Executive Summary

  • The previous inline-inference finding is resolved: make_result() now uses safe_inference().
  • [Newly identified] Staggered-adoption ATTs are incorrectly treated as having one common exposure duration, silently mis-scaling lift, ROI, and SE.
  • The documented Meridian independent-experiment pooling policy remains P3 informational.
  • Focused tests could not run because pytest and numpy are unavailable; syntax compilation passed.

Methodology

P0 — [Newly identified] Staggered ATTs use an invalid common-duration multiplier

Severity: P0

Impact: Both exporters compute att * n_units * n_periods and scale SE identically (diff_diff/mmm.py:L317-L334, diff_diff/mmm.py:L572-L585). However, advertised CallawaySantAnnaResults.att is a weighted average across cohort-time cells:

Σ n_g ATT(g,t) / Σ n_g

where each cohort contributes once per eligible post-treatment period (diff_diff/staggered_aggregation.py:L99-L156). Its correct total scale is therefore Σ_g n_g T_g, not generally total_units × one integer n_periods. Unequal cohort durations, missing cells, or unbalanced panels silently produce incorrect delta_y, sigma, ROI mean, and ROI SD. The cross-estimator test currently blesses the invalid generic multiplier at tests/test_mmm.py:L481-L486.

The MMM Registry repeats the common-duration formula but does not label this incompatibility as a Note/Deviation; the temporal-export TODO does not mitigate silent incorrect scalar output.

Concrete fix: Fail closed for staggered result surfaces unless an exact positive aggregation scale or explicit aggregate effect-and-SE pair is supplied. Alternatively, derive the scale from the same eligible cohort-time weights used by the estimator. Document that automatic multiplication requires an additive level-scale outcome and a common treatment window.

P3 — Documented Meridian pooled-SD policy

Severity: P3 informational

Impact: sqrt(sum((w_i * sd_i)^2)) assumes independent experiments.

Concrete fix: None required; this deviation is explicitly labeled **Note:** in docs/methodology/REGISTRY.md:L5790.

Code Quality

P2 — n_periods validation is conditional despite the Registry contract

Severity: P2

Impact: With aggregate="mean", invalid supplied values such as n_periods=-1 are accepted and silently ignored (diff_diff/mmm.py:L294-L326).

Concrete fix: Validate every non-None count regardless of aggregation mode, or reject n_periods when it is inapplicable.

Performance

P2 — Wrong-sign filtering rebuilds a set per row

Severity: P2

Impact: set(wrong_sign_rows) is reconstructed inside the comprehension, making filtering potentially quadratic (diff_diff/mmm.py:L352).

Concrete fix: Build the set once before filtering.

Maintainability

No additional findings.

Tech Debt

P3 — Follow-up exporters are tracked

Severity: P3 informational

Impact: Temporal lift, Robyn, GeoX, and Meridian calibration-period work are documented in TODO.md.

Concrete fix: None required for this PR.

Security

No findings.

Documentation/Tests

P2 — Cross-estimator test checks plumbing, not estimator semantics

Severity: P2

Impact: The Callaway–Sant’Anna test verifies only the exporter’s own hard-coded multiplication, so it cannot detect the P0 weighting mismatch.

Concrete fix: Add an unequal-cohort-duration fixture and verify exact eligible cohort-time scaling, or assert that staggered results fail closed without an explicit aggregate scale.

Path to Approval

  1. Reject staggered aggregate results without an exact aggregation scale, or implement scaling from their eligible cohort-time weights.
  2. Add a regression with unequal cohort sizes/durations verifying both effect and SE scaling.
  3. Document and enforce the additive level-outcome/common-window assumption.

@github-actions

Copy link
Copy Markdown

🔁 AI review rerun (requested by @igerber)

Head SHA: 92c32d484fdd5a302ad5b9bdeb2e9726623eff60


Overall Assessment

Blocker — two P0 silent scaling errors remain.

Executive Summary

  • The original Callaway–Sant’Anna generic-multiplier issue is addressed.
  • The new unit_periods escape incorrectly scales estimators whose headline uses non-count weights.
  • [Newly identified] Static HeterogeneousAdoptionDiD results are dose-normalized but silently treated as outcome-level ATT.
  • The prior P2 validation/performance findings remain.
  • Tests could not run because NumPy, pandas, SciPy, and pytest are unavailable; syntax parsing passed.

Methodology

P0 — unit_periods does not preserve several listed estimands

Severity: P0

Impact: The exporters accept unit_periods before checking result type and always compute att * unit_periods, scaling SE identically (diff_diff/mmm.py:L361-L397, diff_diff/mmm.py:L641-L685). This is valid for count-weighted Callaway–Sant’Anna simple aggregation, but not generally for the entire allowlist:

  • StackedDiD equally averages horizon coefficients (diff_diff/stacked_did.py:L864-L878).
  • ContinuousDiD assigns each cohort its population weight, then divides that weight equally among its post-treatment cells (diff_diff/continuous_did.py:L823-L858).
  • dCDH can expose a cumulative-dose-weighted cost-benefit delta, not an average treated-cell effect (diff_diff/chaisemartin_dhaultfoeuille.py:L3581-L3605).
  • LPDiD may expose a variance-weighted regression estimand (diff_diff/lpdid_results.py:L58-L60).

For these methods, multiplying by the number of treated unit-periods silently produces incorrect lift, ROI, and SE. The incremental_outcome alternative also infers its SE as se * abs(total / att), which is invalid when the supplied total uses different aggregation weights.

The Registry’s blanket statement at docs/methodology/REGISTRY.md:L5778-L5786 repeats the incorrect identity; it does not mitigate silent incorrect output.

Concrete fix: Permit deterministic scaling only for result/configuration combinations whose headline is verified to be count-weighted. Fail closed for the others, or require both an explicit aggregate outcome and its aggregate SE. Do not infer aggregate SE from a realized point-estimate ratio.

P0 — [Newly identified] Dose-normalized WAS is exported as outcome-level lift

Severity: P0

Impact: Exact-name denylisting allows HeterogeneousAdoptionDiDResults through the generic path (diff_diff/mmm.py:L144-L179). Its att and se are beta-scale, divided by mean treatment dose (diff_diff/had.py:L230-L256), but the exporters multiply only by units/periods (diff_diff/mmm.py:L384-L397, diff_diff/mmm.py:L671-L685). They omit the dose scale, silently misreporting lift, ROI, and uncertainty.

Exact-name detection also fails open for subclasses of listed staggered result classes.

Concrete fix: Reject dose-normalized and unknown estimands by default. Alternatively, add a validated HAD-specific adapter using the appropriate dose denominator and matching SE transformation. Replace the denylist with explicit supported-estimand dispatch.

P3 — Documented independent-experiment pooling

Severity: P3 informational

Impact: Pooled ROI SD assumes independent experiments.

Concrete fix: None required; documented at docs/methodology/REGISTRY.md:L5791.

Code Quality

P2 — Supplied counts are not consistently validated

Severity: P2

Impact: Under aggregate="mean", a supplied invalid n_periods, such as -1, is silently ignored despite the Registry’s positive-integer contract.

Concrete fix: Validate every non-None count regardless of aggregation mode, or reject inapplicable parameters.

Performance

P2 — Wrong-sign filtering rebuilds a set per row

Severity: P2

Impact: set(wrong_sign_rows) is reconstructed inside the comprehension (diff_diff/mmm.py:L421).

Concrete fix: Construct the set once before filtering.

Maintainability

No additional findings beyond the fail-open result-type dispatch described above.

Tech Debt

P3 — Follow-up integrations are tracked

Severity: P3 informational

Impact: Temporal export, Meridian masks, Robyn, GeoX, and tutorial integration are recorded in TODO.md.

Concrete fix: None required.

Security

No findings.

Documentation/Tests

P2 — Tests validate exporter arithmetic, not estimand compatibility

Severity: P2

Impact: The CS tests assert output == cs.att * unit_periods but do not independently reconstruct the aggregate. There are no tests for StackedDiD, ContinuousDiD, dCDH, LPDiD, or static HAD semantics (tests/test_mmm.py:L522-L610).

Concrete fix: Add heterogeneous-effect fixtures with unequal exposure counts and verify manual totals, rejection for incompatible headlines, and explicit aggregate effect/SE handling.

Path to Approval

  1. Restrict unit_periods to verified count-weighted estimands; fail closed elsewhere.
  2. Require explicit aggregate outcome and aggregate SE when no deterministic scale exists.
  3. Reject or correctly dose-scale static HeterogeneousAdoptionDiD results.
  4. Add regression tests for CS, StackedDiD/ContinuousDiD or dCDH, and static HAD.

@github-actions

Copy link
Copy Markdown

🔁 AI review rerun (requested by @igerber)

Head SHA: b8aa290d058863838cab5a63ad8225ba237c389a


Overall Assessment

Blocker — one P0 silent estimand-scaling error remains.

Executive Summary

  • Previous Callaway–Sant’Anna, StackedDiD, and HAD scaling findings are addressed.
  • [Newly identified] TWFE results pass the new allowlist because TWFE returns DiDResults.
  • Meridian’s explicit-outcome path still silently ignores n_periods.
  • Syntax validation passed; tests could not run because pytest is unavailable.

Methodology

P0 — [Newly identified] TWFE is misclassified as 2×2 DiD

Severity: P0

Impact: _scaling_tier() classifies solely by type name, treating every DiDResults as a count-weighted 2×2 estimate (diff_diff/mmm.py:L178-L184). However, TwoWayFixedEffects.fit() returns that exact class (diff_diff/twfe.py:L127-L136, diff_diff/twfe.py:L754-L773). Consequently, both exporters silently multiply a potentially staggered, negative-weight TWFE coefficient and its SE by unit-period counts (diff_diff/mmm.py:L381-L429, diff_diff/mmm.py:L717-L755).

This contradicts the Registry’s “verified” 2×2 classification (docs/methodology/REGISTRY.md:L5778) and its TWFE warning about negative weights under heterogeneous staggered effects (docs/methodology/REGISTRY.md:L334-L348). Unrelated classes named DiDResults also pass.

Concrete fix: Dispatch on explicit estimator/estimand provenance, not the result class name. Add a stable marker distinguishing DifferenceInDifferences from TWFE, or remove DiDResults from deterministic scaling until provenance is available. Reject missing/unknown provenance.

P3 — Documented numerical policies

Severity: P3 informational

Impact: The balanced-common-window assumption and independent-experiment ROI-SD pooling are documented under **Note:** labels.

Concrete fix: None required.

Code Quality

P2 — n_periods is silently ignored with explicit outcomes

Severity: P2

Impact: The explicit Meridian path claims all scaling arguments are mutually exclusive but checks only unit_periods and n_units; supplied n_periods is ignored (diff_diff/mmm.py:L695-L701).

Concrete fix: Include n_periods_list[i] in the exclusivity check and add a regression test.

Performance

No findings. The previous repeated-set construction was fixed.

Maintainability

No additional findings beyond the result-provenance dispatch problem.

Tech Debt

P3 — Follow-up integrations are tracked

Severity: P3 informational

Impact: Meridian masks, tutorial integration, temporal export, Robyn, and GeoX are tracked in TODO.md.

Concrete fix: None required.

Security

No findings.

Documentation/Tests

P2 — Documentation and tests miss the shared result class

Severity: P2

Impact: The guide says any scalar att/se result qualifies, then later describes a restrictive allowlist (diff_diff/guides/llms-full.txt:L2737-L2745, L2804-L2815). Allowlist tests use fabricated names and never fit TWFE, so the collision is missed (tests/test_mmm.py:L640-L687).

Concrete fix: Clarify derived-versus-explicit export eligibility and test rejection of a real TWFE result plus an unrelated same-named class.

Path to Approval

  1. Make deterministic scaling require explicit 2×2 DiD provenance; reject TWFE and unknown/same-named classes.
  2. Add regression tests proving both exporters reject a fitted staggered TWFE result while Meridian’s explicit outcome/SE path remains supported.

@github-actions

Copy link
Copy Markdown

🔁 AI review rerun (requested by @igerber)

Head SHA: e35f98566a686dd311ce9b094cc5e98535727f0e


Overall Assessment

Blocker — three newly identified P0 correctness paths remain.

Executive Summary

  • Prior TWFE provenance and explicit-n_periods findings are resolved.
  • Unbalanced MultiPeriodDiD results can be silently mis-scaled.
  • Survey-weighted Callaway–Sant’Anna results do not have an integer unit-period scale.
  • Finite inputs can overflow into non-finite lift/prior outputs.
  • Static syntax validation passed; runtime tests were unavailable because pytest/numpy are not installed.

Methodology

P0 — [Newly identified] Unbalanced MultiPeriodDiD silently violates the scaling assumption

Impact: The Registry permits unbalanced MultiPeriodDiD, where units contribute only to observed periods (docs/methodology/REGISTRY.md:L293-L295). Its headline is an equal-period average (diff_diff/estimators.py:L2323-L2343), but the exporters accept every MultiPeriodDiDResults as common-window and multiply by one n_units × n_periods scale (diff_diff/mmm.py:L186-L212, L448-L461, L779-L793). With period-specific treated counts, both total lift and SE require a count-weighted contrast; the current scalar multiplication silently returns incorrect delta_y, sigma, ROI, and ROI SD.

The MMM Registry notes the balanced-panel assumption (docs/methodology/REGISTRY.md:L5778) but the implementation neither verifies nor records it.

Concrete fix: Stamp balance/post-period treated-count provenance on MultiPeriodDiDResults and reject derived export unless all exported post periods share the same treated population. Route unbalanced results through explicit incremental outcome and SE inputs.

P0 — [Newly identified] Survey-weighted Callaway–Sant’Anna has no valid integer unit_periods scale

Impact: Callaway aggregation uses survey-weighted cohort masses when survey weights exist (diff_diff/staggered_aggregation.py:L94-L112), while MMM export requires an integer unit_periods and treats it as raw treated unit-period count (diff_diff/mmm.py:L225-L231, L761-L793). With non-uniform survey weights, overall_att × raw_unit_periods is not the weighted total, and the exact aggregation denominator may be fractional or arbitrary under rescaled pweights. Both exporters therefore silently mis-scale the point estimate and SE.

Concrete fix: Reject result-derived scaling for non-uniform survey-weighted Callaway results and require the explicit incremental-outcome/SE path. If weighted scaling is supported later, carry an explicit, interpretable aggregation mass in the result rather than accepting raw integer counts.

P3 — Documented pooling policy

Impact: Independent-experiment ROI-SD pooling is explicitly documented under a **Note:** in docs/methodology/REGISTRY.md:L5791.

Concrete fix: None required.

Code Quality

P0 — [Newly identified] Arithmetic can emit non-finite calibration outputs

Impact: Inputs are checked before arithmetic, but scaled outputs and pooled values are not checked (diff_diff/mmm.py:L460-L461, L792-L814). For example, finite att=1e308 and n_units=2 produce infinite lift/ROI; inf / inf then produces mu=NaN and sigma=NaN, which are returned without error. Underflow can similarly produce a zero lognormal scale.

Concrete fix: Validate finiteness and positivity after every scaling/pooling stage, including delta_y, sigma, ROI moments, and lognormal parameters. Raise rather than return non-finite or zero-uncertainty calibration data.

Performance

No findings.

Maintainability

No additional findings.

Tech Debt

P3 — Deferred integrations are tracked

Impact: Meridian masks, temporal lift export, Robyn, GeoX, and tutorial integration are recorded in TODO.md.

Concrete fix: None required.

Security

No findings.

Documentation/Tests

P2 — Missing regressions for unsupported scaling states

Impact: Tests cover balanced, unweighted fixtures but not unbalanced MultiPeriodDiD, survey-weighted Callaway aggregation, or post-arithmetic overflow (tests/test_mmm.py:L585-L608).

Concrete fix: Add tests proving these derived paths reject, plus tests that extreme finite inputs cannot return Inf/NaN calibration fields.

Path to Approval

  1. Reject or correctly count-weight unbalanced MultiPeriodDiD exports in both exporters.
  2. Reject non-uniform survey-weighted Callaway derived scaling and test the explicit Meridian path.
  3. Add post-arithmetic finite/positive guards and overflow/underflow regression tests.

@github-actions

Copy link
Copy Markdown

🔁 AI review rerun (requested by @igerber)

Head SHA: ac7c191717c7a6b080a1f10cc144aadddf3bc5e2


Overall Assessment

Looks good — all prior P0 findings are resolved; no unmitigated P0/P1 findings remain.

Executive Summary

  • Exporters now require explicitly scoped effects and no longer mis-scale unbalanced or survey-weighted estimator results.
  • Meridian outputs are checked for non-finite values after arithmetic.
  • Lognormal conversion matches Meridian; independent-experiment pooling is a documented deviation.
  • Remaining issues are P2/P3 documentation, numerical-error handling, and CI-maintainability concerns.
  • Runtime tests were unavailable because numpy/pandas/scipy/pytest are not installed; static syntax validation passed.

Methodology

P3 — Documented independent-experiment variance policy

Impact: Pooled ROI uncertainty omits covariance between experiments, potentially understating uncertainty for shared controls/windows. This is explicitly documented in docs/methodology/REGISTRY.md:L5787-L5791.

Concrete fix: None required; use se_widening as documented.

Code Quality

P2 — Extreme arithmetic does not consistently follow the validation contract

Impact: x + delta_x can overflow without rejection, while pooled SD squaring can raise raw OverflowError before the promised ValueError guard (diff_diff/mmm.py:L263-L279, diff_diff/mmm.py:L589-L599). No incorrect prior is returned, but downstream behavior is inconsistent.

Concrete fix: Require finite post-test spend and compute pooled SD with a scaled norm such as math.hypot; add extreme-value regressions.

Performance

No findings.

Maintainability

P2 — Documentation IA regression guards were removed

Impact: Deleting tests/test_docs_ia.py and its workflow step leaves root navigation, tutorial registration, catalog parity, and canonical cross-reference invariants unenforced (.github/workflows/docs-tests.yml:L71-L89).

Concrete fix: Restore the structural test and CI step, or replace them with equivalent checks.

Tech Debt

P3 — Result-derived aggregation remains tracked

Impact: Estimator-owned aggregation and Meridian mask/tutorial helpers are recorded in DEFERRED.md:L73 and TODO.md:L46-L47.

Concrete fix: None required for this PR.

Security

No findings.

Documentation/Tests

P2 — Geo example assigns an aggregate SDID ATT to one geo

Impact: The examples fit SDID over a panel but label the resulting aggregate ATT as US-CA, contrary to the same-population contract (docs/api/mmm.rst:L48-L63, diff_diff/guides/llms-full.txt:L2757-L2769). Copying this with multiple treated geos would silently miscalibrate that coordinate.

Concrete fix: State that the panel has exactly one treated geo, or omit the geo dimension and use aggregate-matched spend/effect inputs.

P3 — Callaway workflow demonstrates unsupported headline scaling

Impact: cs.att * unit_periods need not reproduce the estimator’s aggregation weights or variance (tests/test_mmm.py:L406-L418). Automatic result aggregation is already tracked in DEFERRED.md.

Concrete fix: Replace this test with caller-supplied known totals and SEs until estimator-owned aggregation exists.

@github-actions

Copy link
Copy Markdown

🔁 AI review rerun (requested by @igerber)

Head SHA: 02167f8d4a9ade4fe32b824d8b8c71df4e7ba76b


Overall Assessment

Looks good — no unmitigated P0/P1 findings.

Executive Summary

  • Prior correctness and documentation findings are resolved.
  • PyMC and Meridian methodology matches the registry and cited formulas.
  • Independent-experiment variance pooling is a documented deviation.
  • One P2 numerical edge case remains.
  • Runtime tests were unavailable; static syntax checks passed.

Methodology

P3 — Documented independent-experiment variance policy

Impact: Pooled ROI uncertainty omits covariance across experiments and may be anti-conservative for overlapping samples/windows.

Concrete fix: None required; this is documented in docs/methodology/REGISTRY.md:L5786-L5792, with se_widening provided as mitigation.

Code Quality

P2 — Extreme ROI coefficient-of-variation raises raw OverflowError

Impact: Finite inputs such as incremental_outcome=1e-300, incremental_outcome_se=1, and spend=1 reach (roi_sd / roi_mean) ** 2, which overflows before the validation guard. No incorrect prior is emitted, but the documented validation contract is bypassed (diff_diff/mmm.py:L613-L623).

Concrete fix: Compute log(1 + (sd/mean)²) in the log domain, such as with np.logaddexp, and add a regression alongside tests/test_mmm.py:L305-L326.

Performance

No findings.

Maintainability

No findings. The prior documentation-IA concern is resolved: tests/test_docs_ia.py and its CI invocation are present.

Tech Debt

P3 — Follow-up automation is tracked

Impact: Result-derived aggregation and automatic Meridian calibration-mask construction remain manual.

Concrete fix: None required for this PR; tracked in DEFERRED.md:L73 and TODO.md:L46-L47.

Security

No findings.

Documentation/Tests

No findings. The geo example now states its single-treated-geo requirement, and the Callaway example no longer demonstrates naïve headline-ATT scaling. The test suite could not run because pytest/numpy/pandas/scipy are unavailable; changed Python files and generated snippet syntax passed static parsing.

@github-actions

Copy link
Copy Markdown

🔁 AI review rerun (requested by @igerber)

Head SHA: 04ce57f91128ff7a0d94715ccae532b12bfcf029


Overall Assessment

⚠️ Needs changes — one unmitigated P1 edge-case defect.

Executive Summary

  • Previous ROI overflow finding is resolved with stable numerical operations and tests.
  • Meridian pooling and lognormal conversion match the registry.
  • Documented independent-experiment variance policy is informational only.
  • A floating-point underflow can bypass PyMC’s wrong-sign policy.
  • Runtime tests were unavailable; static parsing and diff checks passed.

Methodology

P1 — [Newly identified] Wrong-sign detection can underflow

Location: diff_diff/mmm.py:L284-L287

Impact: delta_x=1e-200 and delta_y=-1e-200 produce delta_x * delta_y == -0.0, so the documented raise/drop policy is bypassed. An invalid row reaches PyMC-Marketing and may fail there with NonMonotonicError.

Concrete fix: Compare signs directly, for example (dx_i < 0) != (dy_i < 0), after the existing zero checks.

P3 — Documented independent-experiment variance policy

Location: docs/methodology/REGISTRY.md:L5791

Impact: Pooled uncertainty omits covariance and may be anti-conservative for overlapping experiments.

Concrete fix: None required; the deviation and se_widening mitigation are documented.

Code Quality

No additional findings. The prior extreme-ROI OverflowError is resolved by math.hypot and log-domain conversion at diff_diff/mmm.py:L591-L619.

Performance

No findings.

Maintainability

No findings.

Tech Debt

P3 — Follow-up automation is tracked

Impact: Result-derived scaling and Meridian mask construction remain manual.

Concrete fix: None required; tracked in DEFERRED.md:L73 and TODO.md:L46-L47.

Security

No findings.

Documentation/Tests

The new numerical regression tests cover the prior finding. Runtime tests could not run because required dependencies are unavailable; syntax and diff checks passed.

Path to Approval

  1. Replace multiplication-based sign detection with a direct sign comparison and add a regression using opposite-signed values around 1e-200 for both raise and drop.

@github-actions

Copy link
Copy Markdown

🔁 AI review rerun (requested by @igerber)

Head SHA: 60b64ce68a1d5462cefe7872bcbcae28bd6cd283


Overall Assessment

Looks good — the previous P1 is resolved; no unmitigated P0/P1 findings remain.

Executive Summary

  • Direct sign comparison fixes the prior floating-point underflow defect.
  • PyMC schema/guards and Meridian pooling/lognormal math match the Methodology Registry.
  • Independent-experiment variance is an explicitly documented deviation.
  • One minor validation gap can generate invalid Meridian snippet syntax.
  • Runtime tests were unavailable because dependencies are not installed; static compilation passed.

Methodology

P3 — Documented independent-experiment variance policy

Location: docs/methodology/REGISTRY.md:L5791

Impact: Covariance between overlapping experiments is omitted, potentially understating uncertainty.

Concrete fix: None required; the deviation and se_widening mitigation are explicitly documented.

Code Quality

P2 — Empty calibration expression generates invalid Python

Location: diff_diff/mmm.py:L424-L444

Impact: roi_calibration_period="" passes validation but produces a snippet ending with roi_calibration_period=, which raises SyntaxError.

Concrete fix: Reject blank strings, ideally validating the supplied expression with ast.parse(value, mode="eval"), and add a regression test.

Performance

No findings.

Maintainability

No findings.

Tech Debt

P3 — Follow-up automation remains tracked

Location: TODO.md:L46-L47, DEFERRED.md:L73

Impact: Calibration-mask construction and result-derived scaling remain manual.

Concrete fix: None required; both limitations are tracked.

Security

No findings.

Documentation/Tests

No findings. The prior underflow case is now covered at tests/test_mmm.py:L194-L200, and changed Python sources compile successfully.

…sts + Meridian ROI priors

Interop builders that assemble Marketing Mix Model calibration inputs from
experiment results. Explicit-in / validated-out: the caller supplies the
already-scoped incremental outcome and its SE (aggregated to the population and
window one MMM row represents), and diff-diff assembles the target schema,
enforces each consumer's guards, converts to the lognormal parameterization,
and pools. It does NOT rescale a headline ATT - that reconciliation needs the
MMM's row granularity, time window, and outcome scale, which the exporter
cannot see. Pure numpy/pandas; imports no MMM package; introspects no result
object, so the module is purely additive (touches no estimator).

- to_pymc_marketing_lift_test(channel, x, delta_x, delta_y, sigma, dims=,
  on_wrong_sign=): builds the lift-test DataFrame consumed by pymc-marketing's
  MMM.add_lift_test_measurements (prophetverse-compatible). Guards: sigma>0,
  delta_x!=0, x>=0, x+delta_x>=0, finite delta_y; wrong-sign (NonMonotonicError)
  AND zero-lift (degenerate for the Gamma lift likelihood) share one
  on_wrong_sign policy; dims reserved-column + shared-keys validation.
- to_meridian_roi_prior(incremental_outcome, incremental_outcome_se, spend,
  parameter="roi_m"|"mroi_m", se_widening=): builds Google Meridian lognormal
  priors (MeridianROIPrior); mu/sigma match meridian's lognormal_dist_from_mean_std
  (verified 1.7.0), spend-weighted multi-experiment pooling, non-positive-ROI and
  finiteness guards, and a channel- and time-scoped .to_code() snippet that sets
  media_prior_type. roi_m vs mroi_m selects the estimand and each parameter's
  Meridian default for non-experiment channels.
- 43 behavioral tests (schema, all guards, sign/zero policy, lognormal roundtrip
  via scipy, pooling, to_code scoping, realistic DiD/CS workflow) + end-to-end
  smoke verified against real pymc-marketing 0.19.4 (frame accepted into the
  model graph; wrong-signed row raises NonMonotonicError upstream).
- Docs: REGISTRY interop section, api/mmm.rst, llms.txt + llms-full.txt, README
  line, references.rst, doc-deps.yaml, CHANGELOG; TODO/DEFERRED follow-up rows
  (Meridian mask builder, PR-B tutorial, post-4.0 result-derived scaling via
  results.aggregate()).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014wAAYSVo1Yf3XMcxNMrqsj
@github-actions

Copy link
Copy Markdown

🔁 AI review rerun (requested by @igerber)

Head SHA: 5d7dcdd9e8a39fc6258e5375e687abe7d6a6483d


Overall Assessment

Looks good — no unmitigated P0/P1 findings.

Executive Summary

  • The prior mask-expression P2 is resolved with ast.parse(..., mode="eval") and regression coverage.
  • PyMC lift-test validation and Meridian ROI/mROI math align with the Methodology Registry.
  • Independent-experiment variance pooling is an explicitly documented deviation.
  • Runtime tests were unavailable because dependencies are absent; changed sources compile in memory.

Methodology

P3 — Documented independent-experiment variance policy

Location: docs/methodology/REGISTRY.md:L5791, diff_diff/mmm.py:L606-L632

Impact: Correlation between overlapping experiments can understate pooled uncertainty.

Concrete fix: None required; the deviation and se_widening mitigation are documented.

Code Quality

No findings. The prior blank-expression issue is fixed at diff_diff/mmm.py:L440-L449.

Performance

No findings.

Maintainability

No findings.

Tech Debt

P3 — Follow-up automation is tracked

Location: TODO.md:L46-L47, DEFERRED.md:L73

Impact: Calibration masks and result-derived scaling remain caller-managed.

Concrete fix: None required; both limitations are tracked.

Security

No findings.

Documentation/Tests

No findings. Invalid blank expressions are covered at tests/test_mmm.py:L400-L403. Focused pytest execution was unavailable because pytest and scientific dependencies are not installed.

@igerber igerber added the ready-for-ci Triggers CI test workflows label Jul 19, 2026
@igerber
igerber merged commit 79fde65 into main Jul 19, 2026
39 of 40 checks passed
@igerber
igerber deleted the mmm branch July 19, 2026 22:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ready-for-ci Triggers CI test workflows

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant