Skip to content

Commit 0faa01b

Browse files
authored
feat(interop): balance interop launch - composition-drift tutorial, contract tests, isolated interop CI job (#613)
1 parent 81efd2b commit 0faa01b

16 files changed

Lines changed: 1206 additions & 8 deletions

.github/workflows/notebooks.yml

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ on:
88
- 'diff_diff/**'
99
- 'pyproject.toml'
1010
- '.github/workflows/notebooks.yml'
11+
# the interop drift guard runs only in this workflow (balance installed)
12+
- 'tests/test_t26_composition_drift_calibration_drift.py'
1113
pull_request:
1214
branches: [main]
1315
types: [opened, synchronize, reopened, labeled, unlabeled]
@@ -16,6 +18,8 @@ on:
1618
- 'diff_diff/**'
1719
- 'pyproject.toml'
1820
- '.github/workflows/notebooks.yml'
21+
# the interop drift guard runs only in this workflow (balance installed)
22+
- 'tests/test_t26_composition_drift_calibration_drift.py'
1923
schedule:
2024
# Weekly Sunday 6am UTC — smoke test that notebooks still execute cleanly
2125
- cron: '0 6 * * 0'
@@ -58,11 +62,16 @@ jobs:
5862
--nbmake-timeout=600 \
5963
--ignore=docs/tutorials/06_power_analysis.ipynb \
6064
--ignore=docs/tutorials/10_trop.ipynb \
65+
--ignore=docs/tutorials/26_composition_drift_calibration.ipynb \
6166
-v \
6267
--tb=short
6368
# Excluded notebooks (too slow for pure-Python CI without Rust backend):
6469
# 06_power_analysis — SyntheticDiD simulate_power Monte Carlo (>600s)
6570
# 10_trop — LOOCV grid search (>600s)
71+
# Excluded notebooks (external interop dependency):
72+
# 26_composition_drift_calibration — requires the balance package;
73+
# runs in the isolated interop-notebooks job below so this job's
74+
# minimal env keeps enforcing that tutorials add no dependencies
6675
6776
- name: Upload failed notebook outputs
6877
if: failure()
@@ -71,3 +80,59 @@ jobs:
7180
name: failed-notebook-outputs
7281
path: docs/tutorials/*.ipynb
7382
retention-days: 7
83+
84+
interop-notebooks:
85+
name: Execute balance-interop notebook
86+
# Same ready-for-ci label gate as execute-notebooks (keep in sync).
87+
if: >-
88+
github.event_name != 'pull_request'
89+
|| (contains(github.event.pull_request.labels.*.name, 'ready-for-ci')
90+
&& (github.event.action != 'labeled' && github.event.action != 'unlabeled'
91+
|| github.event.label.name == 'ready-for-ci'))
92+
runs-on: ubuntu-latest
93+
94+
steps:
95+
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
96+
97+
- name: Set up Python
98+
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
99+
with:
100+
# 3.12+: balance's legacy numpy<2 / scipy<1.14 / scikit-learn<1.4
101+
# pins apply only to python < 3.12
102+
python-version: '3.12'
103+
104+
- name: Install dependencies
105+
# balance is a tutorial-only dependency: it is installed ONLY in this
106+
# isolated job, never in package requirements or the main notebooks
107+
# job. The weekly cron on this workflow doubles as a cross-package
108+
# integration smoke of diff-diff HEAD against latest PyPI balance.
109+
run: |
110+
pip install numpy pandas scipy matplotlib nbmake pytest ipykernel "balance>=0.21"
111+
# Add repo root to Python path so Jupyter kernels can import diff_diff
112+
# (pip install -e . requires the Rust/maturin toolchain; .pth avoids that)
113+
python -c "import site; print(site.getsitepackages()[0])" | xargs -I{} sh -c 'echo "$PWD" > {}/diff_diff_dev.pth'
114+
115+
- name: Execute interop notebook
116+
env:
117+
DIFF_DIFF_BACKEND: python
118+
run: |
119+
pytest --nbmake docs/tutorials/26_composition_drift_calibration.ipynb \
120+
--nbmake-timeout=600 \
121+
-v \
122+
--tb=short
123+
124+
- name: Run interop drift guard
125+
# balance is present only in this job, so this is the drift test's
126+
# CI home (it importorskips balance everywhere else).
127+
env:
128+
DIFF_DIFF_BACKEND: python
129+
run: |
130+
pytest tests/test_t26_composition_drift_calibration_drift.py -v --tb=short
131+
132+
- name: Upload failed notebook outputs
133+
if: failure()
134+
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
135+
with:
136+
name: failed-interop-notebook-outputs
137+
path: docs/tutorials/26_composition_drift_calibration.ipynb
138+
retention-days: 7

CHANGELOG.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,37 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
3232
## [3.6.2] - 2026-07-03
3333

3434
### Added
35+
- **balance interop launch: composition-drift tutorial + `interop-notebooks` CI job.** Meta's
36+
`balance` package (>= 0.21) ships a one-way adapter `balance.interop.diff_diff`
37+
(facebookresearch/balance PR #465) whose `balance[did]` extra pins `diff-diff>=3.3,<4`.
38+
`docs/tutorials/26_composition_drift_calibration.ipynb` is the diff-diff-side companion to
39+
balance's `balance_diff_diff_brfss` tutorial, telling the failure-mode half of the story: a
40+
BRFSS-style smoking-ban DGP with no systematic arm-specific trends (parallel trends hold in
41+
expectation; planted ATT -3.0pp, realized -2.98pp under a rarely-binding probability floor) where
42+
treatment-correlated non-response drift biases the design-weight Callaway-Sant'Anna ATT to
43+
~-4.1pp with *clean pre-trends*; a per-wave national rake fails (~-4.4pp - margins satisfied in
44+
aggregate while arm-level composition is untouched); per-state raking with balance (BRFSS's own
45+
granularity, population-count totals) recovers ~-3.2pp. Also covers the seam both ways (native
46+
`SurveyDesign` + `aggregate_survey` vs `bd.to_panel_for_did`/`bd.fit_did`, exact-parity assert),
47+
a 3-estimator x 2-weighting sweep, and `as_balance_diagnostic` cross-package diagnostics.
48+
`tests/test_t26_composition_drift_calibration_drift.py` re-derives every quoted number
49+
(auto-skips without balance). balance stays out of package requirements: the tutorial runs in a
50+
new isolated `interop-notebooks` job in `notebooks.yml` (python 3.12, installs
51+
`balance>=0.21`, also the drift guard's CI home; the workflow's weekly cron doubles as a
52+
cross-package integration smoke against latest PyPI balance), and the main notebooks job env is
53+
unchanged.
54+
- **`balance.interop.diff_diff` contract tests.** `tests/test_balance_interop_contract.py` pins
55+
the diff-diff surface Meta's balance adapter consumes, importing no balance code:
56+
`aggregate_survey` forwarded-params superset + `(panel, SurveyDesign)` return schema, the
57+
`SurveyDesign` 15-field dataclass contract (plus TSL / replicate constructions), estimator and
58+
short-alias resolution (`CS`/`DiD`/`BJS`/`HAD`) with `survey_design=` accepted by all 17
59+
promised `fit()` signatures, the `_balance_adjustment` setattr provenance side-channel
60+
(guards against future `__slots__`), the CallawaySantAnna pweight-only guard, and the
61+
`SurveyMetadata.design_effect`/`effective_n`/`sum_weights` attribute names read by
62+
`as_balance_diagnostic`. Docs handoff closing the survey-roadmap Phase 8g gap: "Weight
63+
calibration with balance" section in `docs/api/prep.rst`, calibration pointers in
64+
`llms.txt`/`llms-full.txt`/`llms-practitioner.txt` and `README.md` Survey Support, and
65+
Deville & Särndal (1992) + Sarig, Galili & Eilat (2023) in `docs/references.rst`.
3566
- **`SyntheticControl` ADH-2015 §4 tail diagnostics** (two opt-in `SyntheticControlResults`
3667
methods, closing the last two ADH-2015 §4 checklist items). `regression_weights()` reports the
3768
implied donor weights `W^reg = X0a'(X0a X0a')^{-1} X1a` of the regression counterfactual

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,7 @@ Most estimators accept an optional `survey_design` parameter (or `survey=` / `we
137137
- **Variance methods**: Taylor Series Linearization (TSL via Binder 1983), replicate weights (BRR / Fay / JK1 / JKn / SDR), survey-aware bootstrap
138138
- **Diagnostics**: DEFF per coefficient, effective n, subpopulation analysis, weight trimming, CV on estimates
139139
- **Repeated cross-sections**: `CallawaySantAnna(panel=False)` for BRFSS, ACS, CPS
140+
- **Weight calibration / raking**: upstream by design - pair with Meta's [balance](https://import-balance.org/) package, whose `balance.interop.diff_diff` adapter hands raked samples straight to diff-diff; see the [composition-drift tutorial](https://diff-diff.readthedocs.io/en/stable/tutorials/26_composition_drift_calibration.html)
140141

141142
No other Python or R DiD package offers design-based variance estimation for modern heterogeneity-robust estimators.
142143

diff_diff/guides/llms-full.txt

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1188,6 +1188,22 @@ read-throughs for compatibility with external adapters that
11881188
the canonical names; assume the flat aliases are present on every
11891189
staggered class unless explicitly noted otherwise.
11901190

1191+
**balance interop.** Meta's `balance` package (>= 0.21) ships the
1192+
one-way adapter `balance.interop.diff_diff` (`pip install "balance[did]"`,
1193+
pins `diff-diff>=3.3,<4`): `to_survey_design(sample)` builds a
1194+
`SurveyDesign` from a balance `Sample`'s active weight column plus the
1195+
convention columns `stratum`/`psu`/`fpc`; `to_panel_for_did(sample, by=,
1196+
outcomes=)` wraps `diff_diff.aggregate_survey` to collapse respondent
1197+
microdata into a unit-period panel plus second-stage design;
1198+
`fit_did(sample, estimator=, ...)` resolves any exported estimator by
1199+
name or short alias (`CS`/`DiD`/`BJS`/`HAD`) and forwards
1200+
`survey_design=`, attaching the source Sample to the result as
1201+
`_balance_adjustment` for provenance; `as_balance_diagnostic(sample,
1202+
res)` joins balance's ASMD/Kish-ESS with `res.survey_metadata`'s
1203+
DEFF/effective-n into one flat dict. The diff-diff surface it consumes
1204+
is pinned by `tests/test_balance_interop_contract.py`; the workflow is
1205+
demonstrated in Tutorial 26 (composition drift & calibration).
1206+
11911207
### DiDResults
11921208

11931209
Returned by `DifferenceInDifferences.fit()` and `TwoWayFixedEffects.fit()`.

diff_diff/guides/llms-practitioner.txt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,11 @@ Key questions to answer:
4848
units.
4949
- Is there treatment effect heterogeneity you should preserve rather than
5050
average over?
51+
- If the data are a survey: are the weights calibrated (raked) at the
52+
granularity of your comparison units? Non-response drift that correlates
53+
with treatment timing does NOT difference out of a DiD; calibrate upstream
54+
with Meta's balance package first — see Tutorial 26:
55+
docs/tutorials/26_composition_drift_calibration.ipynb.
5156

5257
```python
5358
# After estimation, the target parameter is available as:

diff_diff/guides/llms.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,7 @@ Full practitioner guide: call `diff_diff.get_llm_guide("practitioner")`
101101
- [16 Survey DiD](https://diff-diff.readthedocs.io/en/stable/tutorials/16_survey_did.html): Survey-weighted DiD — SurveyDesign, strata/PSU/FPC, replicate weights, subpopulation analysis, DEFF diagnostics
102102
- [16 Wooldridge ETWFE](https://diff-diff.readthedocs.io/en/stable/tutorials/16_wooldridge_etwfe.html): Wooldridge (2023, 2025) ETWFE — saturated OLS, logit/Poisson (ASF-based ATT), aggregation types
103103
- [22 HAD Survey-Weighted Workflow](https://diff-diff.readthedocs.io/en/stable/tutorials/22_had_survey_design.html): HeterogeneousAdoptionDiD + did_had_pretest_workflow under SurveyDesign(strata, psu, weights, fpc) — BRFSS-shape panel, modest SE inflation explanation, Phase 4.5 C0 QUG-deferred verdict
104+
- [26 Composition Drift & Calibration](https://diff-diff.readthedocs.io/en/stable/tutorials/26_composition_drift_calibration.html): When differential non-response biases the DiD itself — per-state raking with Meta's balance package, `balance.interop.diff_diff` adapter, raking-granularity lesson (requires `pip install balance`)
104105

105106
## Survey Support
106107

@@ -110,6 +111,7 @@ Most estimators accept an optional `survey_design` parameter (`SyntheticControl`
110111
- **Variance methods**: Taylor Series Linearization (TSL), replicate weights (BRR/Fay/JK1/JKn/SDR), survey-aware bootstrap
111112
- **Diagnostics**: DEFF per coefficient, effective n, subpopulation analysis, weight trimming, CV on estimates
112113
- **Repeated cross-sections**: `CallawaySantAnna(panel=False)` for BRFSS, ACS, CPS
114+
- **Weight calibration / raking**: upstream by design — pair with Meta's [balance](https://import-balance.org/) package (>= 0.21), whose `balance.interop.diff_diff` adapter (`to_survey_design` / `to_panel_for_did` / `fit_did` / `as_balance_diagnostic`, `pip install "balance[did]"`) hands raked samples straight to diff-diff estimators; see [Tutorial 26](https://diff-diff.readthedocs.io/en/stable/tutorials/26_composition_drift_calibration.html) for when calibration is essential for the causal estimand itself
113115
- **Compatibility matrix**: [Survey Design Support](https://diff-diff.readthedocs.io/en/stable/choosing_estimator.html#survey-design-support)
114116

115117
No R or Python package offers design-based variance estimation for modern heterogeneity-robust DiD estimators. R's `did`, `fixest`, `synthdid`, and `didimputation` accept flat weight vectors only.

docs/api/prep.rst

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -366,6 +366,41 @@ Example
366366
# treatment="treated", time="post", survey_design=stage2,
367367
# )
368368
369+
Weight calibration with balance
370+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
371+
372+
``SurveyDesign`` expects **pre-calibrated** weights: post-stratification,
373+
raking, and calibration are deliberately out of scope for diff-diff and
374+
remain upstream. Meta's `balance <https://import-balance.org/>`_ package
375+
(>= 0.21) is the recommended companion - it rakes survey samples to
376+
population margins and ships a dedicated adapter,
377+
``balance.interop.diff_diff``, that hands the calibrated sample straight
378+
to diff-diff (``to_survey_design`` / ``to_panel_for_did`` / ``fit_did`` /
379+
``as_balance_diagnostic``; installable via ``pip install "balance[did]"``).
380+
381+
The handoff needs no adapter if you prefer the native seam - calibrated
382+
weights are just a column::
383+
384+
design = SurveyDesign(weights="raked_wt", strata="strat", psu="psu")
385+
panel, stage2 = aggregate_survey(
386+
microdata, by=["state", "year"], outcomes="smoking_rate",
387+
survey_design=design,
388+
)
389+
result = CallawaySantAnna().fit(
390+
panel, outcome="smoking_rate_mean", unit="state", time="year",
391+
first_treat="g", survey_design=stage2,
392+
)
393+
394+
**When calibration matters for the causal estimand** (not just
395+
descriptives): non-response drift that is differential by treatment arm
396+
and time does *not* difference out of a DiD. See the
397+
:doc:`composition-drift tutorial <../tutorials/26_composition_drift_calibration>`
398+
for a worked BRFSS-style failure mode - including why raking granularity
399+
must match the comparison units (state-level raking, as BRFSS itself
400+
does, not a pooled national rake) - and the companion
401+
`balance tutorial <https://github.com/facebookresearch/balance/blob/main/tutorials/balance_diff_diff_brfss.ipynb>`_
402+
for the robust case (common drift) and descriptive-estimand repair.
403+
369404
Data Validation
370405
---------------
371406

docs/doc-deps.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -846,6 +846,9 @@ sources:
846846
type: roadmap
847847
- path: docs/tutorials/16_survey_did.ipynb
848848
type: tutorial
849+
- path: docs/tutorials/26_composition_drift_calibration.ipynb
850+
type: tutorial
851+
note: "balance interop: calibration handoff + composition-drift failure mode"
849852
- path: README.md
850853
section: "Survey Support"
851854
type: user_guide
@@ -953,6 +956,9 @@ sources:
953956
docs:
954957
- path: docs/api/prep.rst
955958
type: api_reference
959+
- path: docs/tutorials/26_composition_drift_calibration.ipynb
960+
type: tutorial
961+
note: "aggregate_survey is the seam balance.interop.diff_diff wraps"
956962
- path: docs/practitioner_getting_started.rst
957963
type: user_guide
958964
- path: docs/practitioner_decision_tree.rst

docs/index.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ Quick Links
8484
tutorials/21_had_pretest_workflow
8585
tutorials/22_had_survey_design
8686
tutorials/23_spillover_tva
87+
tutorials/26_composition_drift_calibration
8788

8889
.. toctree::
8990
:maxdepth: 1

docs/references.rst

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,14 @@ Survey-Design Inference (Taylor-Series Linearization)
106106

107107
The "when to weight" framework distinguishing precision, endogenous-sampling, and population-effect motivations for survey weights; cited in REGISTRY.md ``## Survey Data Support`` -> "Weighted Estimation".
108108

109+
- **Deville, J.-C. & Särndal, C.-E. (1992).** "Calibration Estimators in Survey Sampling." *Journal of the American Statistical Association*, 87(418), 376-382. https://doi.org/10.1080/01621459.1992.10475217
110+
111+
The calibration/raking framework underlying post-stratified survey weights. diff-diff deliberately keeps calibration upstream (``SurveyDesign`` expects pre-calibrated weights); the ``docs/api/prep.rst`` "Weight calibration with balance" section and Tutorial 26 document the handoff.
112+
113+
- **Sarig, T., Galili, T. & Eilat, R. (2023).** "balance - a Python package for balancing biased data samples." *arXiv:2307.06024* (stat.CO). https://arxiv.org/abs/2307.06024
114+
115+
Meta's ``balance`` package, the recommended upstream calibration companion. Its ``balance.interop.diff_diff`` adapter (balance >= 0.21) hands raked samples to diff-diff's survey-aware estimators; Tutorial 26 (``docs/tutorials/26_composition_drift_calibration.ipynb``) demonstrates the workflow, and ``tests/test_balance_interop_contract.py`` pins the consumed surface.
116+
109117
Placebo Tests and DiD Diagnostics
110118
---------------------------------
111119

0 commit comments

Comments
 (0)