Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
129 changes: 129 additions & 0 deletions .github/workflows/mmm-interop.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
name: MMM Interop

# Schema-drift canary for the diff_diff.mmm exporters against the REAL MMM
# frameworks (pymc-marketing, google-meridian). Lives in its own workflow file
# because `paths:` is workflow-level: inside notebooks.yml these heavy jobs
# would fire on every notebook-prose PR and pay full TensorFlow/PyMC installs.
#
# Version policy: CI installs FLOATING ranges (like the balance job's
# "balance>=0.21") so the weekly cron genuinely resolves new upstream releases
# and acts as an upstream-drift canary; the tutorial notebooks and their local
# execution venvs stay pinned to exact versions for reproducibility.
#
# Fragile edge: google-meridian pins an exact tfp-nightly build. If that wheel
# ever disappears from PyPI, first bump/resolve google-meridian to the newest
# release; if none resolves, flip this workflow to cron-only in a hotfix PR —
# never leave it red on labeled PRs.

on:
push:
branches: [main]
paths:
# The exporters' behavior is owned by mmm.py plus the pinned
# AggregationResult container shape (aggregation.py); estimator-internal
# changes cannot alter the exporter contract without touching these, and
# the normal suite owns the container contract itself - so the heavy
# framework installs do not fan out to unrelated library changes.
- 'diff_diff/mmm.py'
- 'diff_diff/aggregation.py'
- 'pyproject.toml'
- 'tests/test_mmm_interop_pymc.py'
- 'tests/test_mmm_interop_meridian.py'
- '.github/workflows/mmm-interop.yml'
pull_request:
branches: [main]
types: [opened, synchronize, reopened, labeled, unlabeled]
paths:
# The exporters' behavior is owned by mmm.py plus the pinned
# AggregationResult container shape (aggregation.py); estimator-internal
# changes cannot alter the exporter contract without touching these, and
# the normal suite owns the container contract itself - so the heavy
# framework installs do not fan out to unrelated library changes.
- 'diff_diff/mmm.py'
- 'diff_diff/aggregation.py'
- 'pyproject.toml'
- 'tests/test_mmm_interop_pymc.py'
- 'tests/test_mmm_interop_meridian.py'
- '.github/workflows/mmm-interop.yml'
schedule:
# Weekly Sunday 7am UTC (offset from notebooks.yml) — upstream-drift canary:
# the floating installs resolve the latest framework releases.
- cron: '0 7 * * 0'

permissions:
contents: read

jobs:
mmm-pymc-interop:
name: PyMC-Marketing interop smoke
# Same ready-for-ci label gate as the notebooks.yml jobs (keep in sync).
if: >-
github.event_name != 'pull_request'
|| (contains(github.event.pull_request.labels.*.name, 'ready-for-ci')
&& (github.event.action != 'labeled' && github.event.action != 'unlabeled'
|| github.event.label.name == 'ready-for-ci'))
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7

- name: Set up Python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: '3.12'

- name: Install dependencies
run: |
pip install numpy pandas scipy pytest "pymc-marketing>=1.0,<2"
# Add repo root to Python path so tests can import diff_diff
# (pip install -e . requires the Rust/maturin toolchain; .pth avoids that)
python -c "import site; print(site.getsitepackages()[0])" | xargs -I{} sh -c 'echo "$PWD" > {}/diff_diff_dev.pth'

- name: Import canary
# Its own step so an install/transitive-import failure FAILS the job
# instead of being swallowed by pytest.importorskip into an all-skipped
# green run.
run: python -c "import pymc_marketing; print(pymc_marketing.__version__)"

- name: Run interop smoke tests
env:
DIFF_DIFF_BACKEND: python
run: |
pytest tests/test_mmm_interop_pymc.py -v --tb=short

mmm-meridian-interop:
name: Meridian interop smoke
# Same ready-for-ci label gate as the notebooks.yml jobs (keep in sync).
if: >-
github.event_name != 'pull_request'
|| (contains(github.event.pull_request.labels.*.name, 'ready-for-ci')
&& (github.event.action != 'labeled' && github.event.action != 'unlabeled'
|| github.event.label.name == 'ready-for-ci'))
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7

- name: Set up Python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: '3.12'

- name: Install dependencies
run: |
pip install numpy pandas scipy pytest "google-meridian>=1.8,<2"
# Add repo root to Python path so tests can import diff_diff
# (pip install -e . requires the Rust/maturin toolchain; .pth avoids that)
python -c "import site; print(site.getsitepackages()[0])" | xargs -I{} sh -c 'echo "$PWD" > {}/diff_diff_dev.pth'

- name: Import canary
# Its own step so an install/transitive-import failure FAILS the job
# instead of being swallowed by pytest.importorskip into an all-skipped
# green run.
run: python -c "import meridian; print(meridian.__version__)"

- name: Run interop smoke tests
env:
DIFF_DIFF_BACKEND: python
run: |
pytest tests/test_mmm_interop_meridian.py -v --tb=short
23 changes: 23 additions & 0 deletions .github/workflows/notebooks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ on:
- '.github/workflows/notebooks.yml'
# the interop drift guard runs only in this workflow (balance installed)
- 'tests/test_t26_composition_drift_calibration_drift.py'
# the MMM tutorial drift guards run as a step in execute-notebooks
- 'tests/test_t29_mmm_calibration_pymc_drift.py'
- 'tests/test_t30_mmm_calibration_meridian_drift.py'
pull_request:
branches: [main]
types: [opened, synchronize, reopened, labeled, unlabeled]
Expand All @@ -20,6 +23,9 @@ on:
- '.github/workflows/notebooks.yml'
# the interop drift guard runs only in this workflow (balance installed)
- 'tests/test_t26_composition_drift_calibration_drift.py'
# the MMM tutorial drift guards run as a step in execute-notebooks
- 'tests/test_t29_mmm_calibration_pymc_drift.py'
- 'tests/test_t30_mmm_calibration_meridian_drift.py'
schedule:
# Weekly Sunday 6am UTC — smoke test that notebooks still execute cleanly
- cron: '0 6 * * 0'
Expand Down Expand Up @@ -63,6 +69,8 @@ jobs:
--ignore=docs/tutorials/06_power_analysis.ipynb \
--ignore=docs/tutorials/10_trop.ipynb \
--ignore=docs/tutorials/26_composition_drift_calibration.ipynb \
--ignore=docs/tutorials/29_mmm_calibration_pymc.ipynb \
--ignore=docs/tutorials/30_mmm_calibration_meridian.ipynb \
-v \
--tb=short
# Excluded notebooks (too slow for pure-Python CI without Rust backend):
Expand All @@ -72,6 +80,21 @@ jobs:
# 26_composition_drift_calibration — requires the balance package;
# runs in the isolated interop-notebooks job below so this job's
# minimal env keeps enforcing that tutorials add no dependencies
# 29/30_mmm_calibration_* — require pymc-marketing / google-meridian,
# which cannot even share ONE environment (arviz conflict) and run
# multi-minute MCMC; executed locally with committed outputs. Their
# drift guards run in the next step, and the mmm-interop.yml workflow
# smoke-tests the exporters against the real frameworks (no sampling).

- name: Run MMM tutorial drift guards
# The MMM notebooks are excluded from execution above, so their committed
# surface is protected here: DGP/sampler/integration needles + diff-diff
# recomputation of the DiD-side numbers (no MMM frameworks needed).
env:
DIFF_DIFF_BACKEND: python
run: |
pytest tests/test_t29_mmm_calibration_pymc_drift.py \
tests/test_t30_mmm_calibration_meridian_drift.py -v --tb=short

- name: Upload failed notebook outputs
if: failure()
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/rust-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ on:
# this suite (enforced by TestRustTestWorkflowPathFilter).
- '.github/workflows/docs-tests.yml'
- '.github/workflows/notebooks.yml'
- '.github/workflows/mmm-interop.yml'
- '.github/workflows/ci-gate.yml'
- '.github/workflows/release-build-check.yml'
# lint.yml carries the ruff/black pin-sync contract locked by
Expand Down Expand Up @@ -51,6 +52,7 @@ on:
# this suite (enforced by TestRustTestWorkflowPathFilter).
- '.github/workflows/docs-tests.yml'
- '.github/workflows/notebooks.yml'
- '.github/workflows/mmm-interop.yml'
- '.github/workflows/ci-gate.yml'
- '.github/workflows/release-build-check.yml'
# lint.yml carries the ruff/black pin-sync contract locked by
Expand Down
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
REGISTRY's LWDiD section gains the maintainer implementation notes (RI
convention, dual overall-ATT conventions, Sec 4.3 rejection, inference
default, API conformance).
- **Executed MMM calibration tutorials + CI interop canary.** Two new tutorials
run the diff-diff -> MMM hand-off end-to-end against the real frameworks:
`29_mmm_calibration_pymc.ipynb` (a staggered geo spend-boost experiment
exported with `to_pymc_marketing_lift_test` and fed to pymc-marketing 1.0's
`MMM.add_lift_test_measurements`) and `30_mmm_calibration_meridian.ipynb` (a
geo-holdout launch exported with `to_meridian_roi_prior` +
`meridian_calibration_mask`, with the generated `to_code()` snippet executed
verbatim in google-meridian 1.8). Both fit the MMM without and with the
calibration and demonstrate the ROI posterior moving to the simulated truth
and tightening, with in-notebook sampler-health and acceptance asserts. The
heavy frameworks stay out of diff-diff's dependencies: the notebooks execute
locally (committed outputs; they cannot even share one environment - the two
frameworks pin conflicting `arviz` ranges) and are guarded by
diff-diff-only drift tests (`tests/test_t29_*`/`test_t30_*`), while a new
`mmm-interop.yml` workflow smoke-tests the exporters against floating
framework releases on exporter-touching PRs (paths-scoped to `mmm.py` +
`aggregation.py`, the two files that own the exporter contract) and a
weekly cron
(`tests/test_mmm_interop_pymc.py`, `tests/test_mmm_interop_meridian.py` -
schema, dims, value-retention, and Meridian defaults-drift pins; the
1.7.0-pinned `to_code()` templates and `_MERIDIAN_PARAM_DEFAULTS` are
execution-validated on Meridian 1.8.0). The practitioner guide's Step 8
gains the route-qualified MMM hand-off pointer.
- **`results.aggregate('total')` - the estimator-owned total incremental
outcome** on CallawaySantAnna, EfficientDiD, ImputationDiD, and TwoStageDiD,
promoted into the library-wide aggregation vocabulary. The single
Expand Down
2 changes: 1 addition & 1 deletion TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,11 +76,11 @@ generic sparse-FE, QR+SVD rank-detection redundancy, `check_finite` bypass — m

| Issue | Location | Origin | Effort | Priority |
|-------|----------|--------|--------|----------|
| Optional scheduled end-to-end execution gate for the MMM tutorials (29/30): a cron-only workflow (or extension of `mmm-interop.yml`) that executes both notebooks in isolated exact-pin environments, so a stale/invalid committed posterior cannot stay green indefinitely - today the hybrid posture (deliberate: notebooks execute locally with committed outputs; CI smoke-tests the exporters without sampling; drift tests pin source + committed-output needles) leaves the MCMC claims un-re-executed in CI | `.github/workflows/mmm-interop.yml`, `docs/tutorials/29_mmm_calibration_pymc.ipynb`, `docs/tutorials/30_mmm_calibration_meridian.ipynb` | mmm-interop | Mid | Low |
| Committed `fixest::feols` event-study golden for TWFE `event_study=True` (within + pooled specs, unbalanced + covariate panels, matched CR1 cluster convention, per-period effects + vcov block) - the in-suite gates are shared-core cross-checks (TWFE-within == MPD-absorb, pooled == MPD bit-exact), so a defect common to the shared core would pass; the live-R harness (`benchmarks/R/benchmark_multiperiod.R`, `feols(y ~ treated * time_f \| unit)`) validated the within design in `docs/benchmarks.rst` but is not a committed regression test - follow the `fixest_did_twfe_golden.json` committed-golden pattern (pytest.skip when absent) | `tests/test_fixest_did_twfe_parity.py`, `benchmarks/R/` | 3(a) R2 | Mid | Medium |
| Type-blind `n_bootstrap` acceptance in already-validated estimators - HAD bool (`isinstance(..., int)` passes `True`, runs as 1 replicate), dCDH bool+float (its bare `< 0` check passes both `True` and `2.5`), TROP float (`2.5` passes the `>= 2` floor), SyntheticDiD float under all three variance methods + bool/negative under jackknife (its floor check is skipped there) - align these local checks with the `utils.validate_n_bootstrap` type guard (M-081 kept them out of the sweep: it scoped to previously-UNvalidated estimators only) | `diff_diff/had.py`, `diff_diff/chaisemartin_dhaultfoeuille.py`, `diff_diff/trop.py`, `diff_diff/synthetic_did.py` | 2(d) PR-B | Quick | Low |
| Evaluate adding the `BaseEstimator` param surface (get_params/set_params) to the exported classes that never had it - `PowerAnalysis`, `LinearRegression`, `BusinessReport`, `DiagnosticReport`, `TWFEWeightsResult` (a NEW public surface, deliberately out of the 2(c)-i pure-refactor scope; `LinearRegression` is the one `fit`-bearing class excluded from the contract suite's roster-completeness test). | `diff_diff/linalg.py`, `diff_diff/power.py` | mixin PR | Mid | Low |
| Tighten the mypy suppressions that back the enforced-zero posture: burn down `prep_dgp`'s per-module `[index]` override (needs a None-vs-array restructure that preserves the seeded RNG stream), and evaluate re-enabling the globally disabled codes (`arg-type`, `return-value`, `var-annotated`, `assignment`) one at a time — `assignment` alone hid several real annotation drifts found during the 2026-07 triage. | `pyproject.toml` `[tool.mypy]`, `diff_diff/prep_dgp.py` | lint-CI | Mid | Low |
| MMM interop PR-B: calibration tutorial notebook (fit DiD/CS -> scope -> `to_pymc_marketing_lift_test` / `to_meridian_roi_prior`) + a `llms-practitioner.txt` Step 8 pointer to the exporters as the MMM hand-off. | `docs/tutorials/`, `diff_diff/guides/llms-practitioner.txt` | mmm-interop | Mid | Low |
| Tracking-file contract guard test: reject NEW active deferred-work pointers at `TODO.md` (deferred rows live in `DEFERRED.md`; allowlist for historical/past-tense prose and actionable-row pointers) and assert rows cross-linking a `docs/v4-deprecations.yaml` `M-xxx` id don't restate ledger status. Origin: tracking-split local review R2. | `tests/`, `TODO.md`, `DEFERRED.md` | tracking-split | Quick | Low |
| Real-data CI canary for dataset-backed replication tests: `test_methodology_lwdid.py`'s Prop 99 / Walmart goldens skip (visibly) when loaders fall back to synthetic; add a lane or canary asserting `df.attrs["source"] == "lwdid_ssc_ancillary"` in CI so network regressions cannot silently de-gate the replication tests. Follow-on from the loader-fallback repair (#723), which made provenance explicit but deliberately did not add a network-dependent CI lane. | `tests/test_methodology_lwdid.py`, `.github/workflows/` | LWDiD validation suite | Quick | Low |
| df-provenance completion follow-up (PR C descoping): a Wooldridge public per-row df channel (a per-key dict that can also carry the hc2_bm BM contrast dofs the scalar `_df_analytic_fallback` cannot represent — the unified surface's Wooldridge df column stays NaN until then); ImputationDiD lead-horizon `event_study_df` (needs the `_compute_lead_coefficients`/`_aggregate_event_study` return-path change plus bootstrap-clearing semantics for post rows); and the `inference_df` (DiD/MPD/SA/StackedDiD) vs `df_inference` (CS/dCDH) adapter-naming unification. NOTE (M-024): StackedDiD's `df_survey=None` on analytical fits is DELIBERATE - the container resolver's `df_inference` fallback not matching `inference_df` is load-bearing (normal-theory FLCI; see the REGISTRY StackedDiD M-024 Note and the pin in `tests/test_event_study_consumers.py`) - the unification must preserve that outcome or renegotiate it explicitly. | `diff_diff/wooldridge_results.py`, `diff_diff/imputation.py`, `diff_diff/results_base.py` | #variance-PR-C | Mid | Low |
Expand Down
19 changes: 19 additions & 0 deletions diff_diff/guides/llms-practitioner.txt
Original file line number Diff line number Diff line change
Expand Up @@ -591,6 +591,25 @@ guidance = practitioner_next_steps(results)
# Returns context-aware suggestions for what to do next
```

### Hand off to an MMM (marketing mix model)

When the DiD measured a geo experiment on a marketing channel, export the
result as MMM calibration input via `diff_diff.mmm` - the route depends on
the grain the target MMM row represents:

- The MMM row IS the campaign total (e.g. Meridian ROI priors):
`results.aggregate('total')` -> `to_meridian_roi_prior(aggregation_result=...,
spend=...)` - the total container arrives pre-scaled; `scale` is not accepted.
- The MMM row's population/period grain differs from the total's span (e.g. a
PyMC-Marketing weekly lift row for a national model):
`results.aggregate('simple')` or `aggregate('group')` plus an explicit
`scale=` -> `to_pymc_marketing_lift_test(aggregation_result=..., scale=...,
x=..., delta_x=...)` - every lift-row field must describe the SAME
observation (same channel, population, and period span).

Executed end-to-end examples: tutorials 29 (PyMC-Marketing lift test) and
30 (Meridian ROI prior + calibration mask).

---

## Common Pitfalls
Expand Down
11 changes: 8 additions & 3 deletions diff_diff/mmm.py
Original file line number Diff line number Diff line change
Expand Up @@ -135,12 +135,16 @@

# Meridian prior parameters this exporter can target, with each one's Meridian
# default LogNormal(mu, sigma) per channel (verified against
# meridian/model/prior_distribution.py at 1.7.0): roi_m is the return on a
# meridian/model/prior_distribution.py at 1.7.0; execution-validated unchanged on
# 1.8.0, 2026-08, and continuously checked by tests/test_mmm_interop_meridian.py's
# defaults-drift canary): roi_m is the return on a
# channel's full spend (zero-spend counterfactual), mroi_m the marginal return.
# Channels without an experiment keep the default in the vector snippet.
_MERIDIAN_PARAM_DEFAULTS = {"roi_m": (0.2, 0.9), "mroi_m": (0.0, 0.5)}

# The .to_code() templates are pinned against google-meridian 1.7.0 (2026-06); they
# The .to_code() templates are pinned against google-meridian 1.7.0 (2026-06) and
# execution-validated on 1.8.0 (2026-08: the generated snippets exec verbatim into a
# ModelSpec that Meridian 1.8.0 accepts); they
# are convenience snippets, not a programmatic contract. Meridian's roi_m/mroi_m have
# batch shape n_media_channels; a scalar LogNormal broadcasts to EVERY channel, so the
# scalar template is gated behind an explicit single_channel opt-in.
Expand Down Expand Up @@ -817,7 +821,8 @@ def to_code(
roi_calibration_period: Optional[Union[str, np.ndarray]] = None,
full_model_window: bool = False,
) -> str:
"""Ready-to-paste Meridian snippet (channel- and time-scoped; 1.7.0 pinned).
"""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
Expand Down
Loading
Loading