Skip to content

Commit f60cbf3

Browse files
fix(bounds): guard horowitz_manski strata against pandas>=3.0 NaN
_create_strata cast qcut bin labels with .astype(str). On a degenerate covariate (constant column -> qcut yields all-NaN), pandas<3.0 stringified NaN to "nan" and accidentally formed one valid stratum, but pandas>=3.0 preserves <NA>, so every per-stratum mask in _hm_point matched nothing and the bounds silently collapsed to 0.0/0.0 with no error. NaN strata are now bucketed into an explicit -1 sentinel, recovering the closed-form bounds. Verified in an isolated venv with numpy 2.4.6/scipy 1.17.1/pandas 3.0.3 (the CI-equivalent latest libs): test_single_stratum_matches_closed_form now passes; numerics unchanged on pandas<3.0. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 18539c2 commit f60cbf3

2 files changed

Lines changed: 48 additions & 23 deletions

File tree

CHANGELOG.md

Lines changed: 38 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,20 @@ All notable changes to StatsPAI will be documented in this file.
9696

9797
### Fixed
9898

99+
- **⚠️ Correctness fix (pandas ≥ 3.0): `sp.horowitz_manski` bounds silently
100+
collapsed to `0.0`/`0.0` when a covariate stratum mapped to NaN.** The
101+
internal `_create_strata` helper discretises continuous covariates with
102+
`pd.qcut`, then cast the bin labels with `.astype(str)`. On a degenerate
103+
covariate (e.g. a constant column, where `qcut(..., duplicates='drop')`
104+
yields all-NaN) pandas < 3.0 stringified NaN to `"nan"` — accidentally
105+
forming one valid stratum — but pandas ≥ 3.0 preserves it as `<NA>`, so every
106+
per-stratum mask matched nothing and the bounds summed to zero with no error.
107+
NaN strata are now bucketed into an explicit `-1` sentinel stratum, recovering
108+
the correct closed-form bounds. The analytic guard
109+
(`tests/test_tierD_bounds_analytic.py::TestHorowitzManskiAnalytic::test_single_stratum_matches_closed_form`)
110+
pins the single-stratum case to the closed form on both pandas 2.x and 3.x.
111+
Numerics are unchanged on pandas < 3.0.
112+
99113
- **⚠️ Functionality fix: `sp.blp` was non-functional on every estimation
100114
path.** The GMM objective called `_gmm_objective(..., maxiter=1000)` but the
101115
parameter is named `maxiter_inner`, so every `sp.blp` call raised
@@ -344,9 +358,9 @@ All notable changes to StatsPAI will be documented in this file.
344358
the seed-42 fixture — `|Δ coefficient| = 1.1e-16` and `|Δ standard error| =
345359
1.4e-17`, i.e. one float64 unit in the last place. `doubleml` remains *not*
346360
a runtime dependency. The measured numbers, software versions, and the
347-
divergence discussion are recorded in a new *Double Machine Learning Parity*
348-
section of `docs/joss_validation_dossier.md`, with a one-command reproduce
349-
path added to `docs/joss_reviewer_guide.md`. Verified by installing the
361+
divergence discussion are recorded in the source-audit evidence trail
362+
(`docs/jss_source_audit_dossier.md` plus the parity artifacts under
363+
`tests/external_parity/`). Verified by installing the
350364
extra and running both `tests/external_parity/test_dml_python_parity.py` and
351365
`tests/reference_parity/test_dml_parity.py` (55 DML tests green).
352366

@@ -639,8 +653,8 @@ All notable changes to StatsPAI will be documented in this file.
639653

640654
### Docs
641655

642-
- Reviewer-facing validation docs (`docs/joss_reviewer_guide.md`,
643-
`docs/joss_validation_dossier.md`, `README.md`) refreshed: the focused
656+
- Reviewer-facing validation docs (`docs/jss_source_audit_dossier.md`,
657+
`Paper-JSS/README.md`, `README.md`) refreshed: the focused
644658
reviewer follow-up regression command is documented, the
645659
`tests/test_joss_reviewer_followups.py` compatibility path is restored for
646660
the public review thread (delegating to
@@ -683,16 +697,17 @@ All notable changes to StatsPAI will be documented in this file.
683697
longer the validation estimand. Users who reported a causal-forest ATE
684698
from `average_treatment_effect` should re-run.
685699
- On a clean-overlap DGP (`e(X)∈[0.30,0.70]`, known ATE = 1) the AIPW
686-
ATE recovers the truth within 1.5 SE and agrees with `grf` at
687-
`rel = 0.037` (`z = 0.69` combined SE). For the JSS source snapshot,
700+
ATE recovers the truth within 0.2 SE and agrees with `grf` at
701+
`rel = 0.001` (`z = 0.019` combined SE); the ATT agrees at
702+
`rel = 0.024` (`z = 0.40`). For the JSS source snapshot,
688703
`13_causal_forest` is now a T3 combined-Monte-Carlo-error pass:
689704
the row is like-for-like AIPW versus `grf` and is graded against
690705
combined sampling error, not sold as deterministic machine-precision
691-
equality. The strictness-tier denominator is
692-
`12 / 27 / 10 / 2 on the 51 R-joined modules`: the forest row shares
693-
the methodological/T4 bucket with the remaining documented classical-SCM
694-
non-uniqueness gap, but it is the only row in that bucket graded as a
695-
T3 combined-Monte-Carlo-error pass.
706+
equality. The strictness-tier denominator is
707+
`41 / 12 / 1 / 1 on the 55 R-joined modules`: the forest row is now
708+
the only moderate-stochastic T3 row, and the remaining
709+
methodological/T4 bucket is the documented classical-SCM
710+
non-uniqueness/reference-disagreement gap.
696711
- Guards: `tests/reference_parity/test_causal_forest_aipw_recovery.py`
697712
(recovery against truth, no R needed) and the tightened
698713
`tests/reference_parity/test_grf_parity.py` (combined-SE parity vs a
@@ -703,8 +718,10 @@ All notable changes to StatsPAI will be documented in this file.
703718
module `tests/r_parity/52_scm_unique`: on a DGP whose synthetic-control
704719
weights are uniquely identified (treated unit exactly a convex
705720
combination of donors in the pre-period), `sp.synth(method="classic")`
706-
recovers the exact weights and gap (pre-RMSE = 0) and agrees with
707-
`Synth::synth` to 0.7 %. For the ambiguous Basque-data row, the parity
721+
recovers the exact weights and gap (pre-RMSE = 0) and agrees with
722+
`Synth::synth` at machine-level point precision after fixing the
723+
predictor-weight vector and tightening the inner `ipop` QP controls.
724+
For the ambiguous Basque-data row, the parity
708725
harness keeps the native default visible as a documented
709726
donor-weight-non-uniqueness/reference-disagreement gap. On the same ADH
710727
special-predictor specification, native StatsPAI tracks Stata `synth`
@@ -793,22 +810,22 @@ All notable changes to StatsPAI will be documented in this file.
793810
than only frozen.
794811
- **`sp.validation_report(collect_tests=True)`** — shells out to
795812
`pytest --collect-only` and returns the authoritative, parametrize-expanded
796-
parity test counts (124 reference-parity, 50 external-parity, 12 coverage
813+
parity test counts (124 reference-parity, 52 external-parity, 12 coverage
797814
Monte Carlo on the current source snapshot); a regression test pins those three to the
798815
JSS manuscript headline so a parity test added/removed without updating the
799816
paper fails CI. Default `validation_report()` path is unchanged (fast,
800817
metadata-only).
801818
- **Strictness-tier breakdown in the Track A parity tables
802819
(`tests/r_parity/compare.py`)** — each module is classified by its
803-
registered point-estimate tolerance into machine / iterative / moderate /
804-
methodological/T4 tiers (12 / 27 / 10 / 2 on the 51 R-joined modules), shown
805-
in the Markdown ledger and the LaTeX appendix caption so a machine-precision
806-
match is not flattened together with a deliberately loose
820+
registered point-estimate tolerance into machine-level / iterative /
821+
moderate / methodological-T4 tiers (41 / 12 / 1 / 1 on the 55
822+
R-joined modules), shown in the Markdown ledger and the LaTeX appendix
823+
caption so a machine-level point-estimate match is not flattened together with a deliberately loose
807824
stochastic or documented-convention tolerance.
808825
- **Stata leg brought to the same rigor as R (`tests/stata_parity/`)**
809826
`_common.do` now writes an inline `provenance` block (engine version,
810827
edition, OS) onto every `*_Stata.json`; `verify_reproduce_stata.py` re-runs
811-
each `.do` on the committed CSV bytes and confirms all 44 Stata modules
828+
each `.do` on the committed CSV bytes and confirms all 53 Stata modules
812829
reproduce **bit-for-bit** (worst rel 0) under Stata 18 MP, including the
813830
iterative-optimiser commands (`set seed 42` + deterministic solvers);
814831
`_capture_stata_env.do` + `_gen_stata_env.py` pin the engine and the
@@ -822,7 +839,7 @@ All notable changes to StatsPAI will be documented in this file.
822839

823840
### Changed — Track A R golden values regenerated under the locked environment
824841

825-
- The current R parity ledger covers 51 rendered R-joined modules under
842+
- The current R parity ledger covers 55 rendered R-joined modules under
826843
R 4.5.2 with the `renv.lock` package set so each is self-describing.
827844
The material parity-status movement is the added `52_scm_unique`
828845
counterpart for classical SCM: an identified synthetic-control DGP now

src/statspai/bounds/partial_id.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -420,9 +420,17 @@ def _create_strata(df: pd.DataFrame, covariates: List[str]) -> pd.Series:
420420
for c in covariates:
421421
col = df[c]
422422
if col.dtype.kind in ('f',) or col.nunique() > 10:
423-
parts.append(pd.qcut(col, q=4, labels=False, duplicates='drop').astype(str))
423+
binned = pd.qcut(col, q=4, labels=False, duplicates='drop')
424+
# qcut yields NaN when the bin edges collapse (constant column)
425+
# or the input is missing. Bucket those rows into an explicit
426+
# sentinel stratum: under pandas>=3.0 astype(str) preserves NaN
427+
# as missing instead of stringifying to "nan", so without this
428+
# the masks in _hm_point match nothing and the bounds silently
429+
# collapse to 0.0.
430+
binned = pd.Series(binned, index=df.index)
431+
parts.append(binned.fillna(-1.0).astype(np.int64).astype(str))
424432
else:
425-
parts.append(col.astype(str))
433+
parts.append(col.fillna(-1).astype(str))
426434
combined = parts[0]
427435
for p in parts[1:]:
428436
combined = combined + '_' + p

0 commit comments

Comments
 (0)