diff-diff 3.10.0__tar.gz → 3.11.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {diff_diff-3.10.0 → diff_diff-3.11.1}/PKG-INFO +4 -3
- {diff_diff-3.10.0 → diff_diff-3.11.1}/README.md +1 -0
- {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/__init__.py +8 -1
- diff_diff-3.11.1/diff_diff/_crossfit.py +606 -0
- diff_diff-3.11.1/diff_diff/_dr_scores.py +506 -0
- diff_diff-3.11.1/diff_diff/_learners.py +562 -0
- {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/_reporting_helpers.py +61 -0
- {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/_staggered_triple_diff_engine.py +14 -2
- {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/aggregation.py +5 -4
- {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/bootstrap_chunking.py +1 -1
- {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/business_report.py +59 -5
- {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/chaisemartin_dhaultfoeuille.py +6 -5
- {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/chaisemartin_dhaultfoeuille_results.py +8 -4
- {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/continuous_did.py +106 -64
- {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/continuous_did_results.py +18 -2
- {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/diagnostic_report.py +70 -19
- diff_diff-3.11.1/diff_diff/dml_did.py +2417 -0
- diff_diff-3.11.1/diff_diff/dml_did_results.py +260 -0
- {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/efficient_did.py +39 -4
- {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/efficient_did_covariates.py +60 -10
- {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/efficient_did_results.py +33 -10
- {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/estimators.py +2 -2
- {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/guides/llms-autonomous.txt +19 -0
- {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/guides/llms-full.txt +35 -2
- {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/guides/llms-practitioner.txt +13 -1
- {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/guides/llms.txt +2 -1
- {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/had.py +19 -4
- {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/had_pretests.py +6 -1
- {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/honest_did.py +66 -86
- {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/imputation.py +13 -1
- {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/imputation_results.py +32 -10
- {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/linalg.py +270 -1
- {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/lpdid.py +1 -1
- {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/lwdid.py +5 -9
- {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/mmm.py +21 -1
- {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/practitioner.py +100 -0
- {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/pretrends.py +33 -16
- {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/results_base.py +37 -11
- {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/spillover.py +4 -1
- {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/stacked_did_results.py +8 -3
- {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/staggered.py +84 -27
- {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/staggered_aggregation.py +5 -4
- {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/staggered_bootstrap.py +14 -12
- {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/staggered_results.py +29 -11
- {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/staggered_triple_diff.py +2 -2
- {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/staggered_triple_diff_results.py +8 -3
- {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/sun_abraham.py +13 -8
- {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/survey.py +1 -1
- {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/synthetic_did.py +5 -0
- {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/triple_diff.py +11 -25
- {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/trop.py +4 -1
- {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/twfe.py +1 -1
- {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/two_stage.py +1 -1
- {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/two_stage_results.py +8 -3
- {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/utils.py +48 -4
- {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/visualization/_event_study.py +133 -18
- {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/visualization/_staggered.py +87 -16
- {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/wooldridge.py +4 -2
- {diff_diff-3.10.0 → diff_diff-3.11.1}/pyproject.toml +3 -3
- {diff_diff-3.10.0 → diff_diff-3.11.1}/rust/Cargo.lock +11 -11
- {diff_diff-3.10.0 → diff_diff-3.11.1}/rust/Cargo.toml +1 -1
- {diff_diff-3.10.0 → diff_diff-3.11.1}/LICENSE +0 -0
- {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/_backend.py +0 -0
- {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/_base.py +0 -0
- {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/_deprecation.py +0 -0
- {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/_guides_api.py +0 -0
- {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/_nprobust_port.py +0 -0
- {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/_rdrobust_port.py +0 -0
- {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/agent_workflow.py +0 -0
- {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/bacon.py +0 -0
- {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/balancing.py +0 -0
- {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/bootstrap_utils.py +0 -0
- {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/chaisemartin_dhaultfoeuille_bootstrap.py +0 -0
- {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/changes_in_changes.py +0 -0
- {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/changes_in_changes_results.py +0 -0
- {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/conformal.py +0 -0
- {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/conley.py +0 -0
- {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/continuous_did_aggregation.py +0 -0
- {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/continuous_did_bspline.py +0 -0
- {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/datasets.py +0 -0
- {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/diagnostics.py +0 -0
- {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/efficient_did_aggregation.py +0 -0
- {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/efficient_did_bootstrap.py +0 -0
- {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/efficient_did_weights.py +0 -0
- {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/guides/__init__.py +0 -0
- {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/imputation_aggregation.py +0 -0
- {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/imputation_bootstrap.py +0 -0
- {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/local_linear.py +0 -0
- {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/lpdid_results.py +0 -0
- {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/lwdid_randomization.py +0 -0
- {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/lwdid_results.py +0 -0
- {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/lwdid_sensitivity.py +0 -0
- {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/lwdid_staggered.py +0 -0
- {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/lwdid_visualization.py +0 -0
- {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/lwdid_wild_bootstrap.py +0 -0
- {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/power.py +0 -0
- {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/prep.py +0 -0
- {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/prep_dgp.py +0 -0
- {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/profile.py +0 -0
- {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/rdd.py +0 -0
- {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/rddensity.py +0 -0
- {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/rdplot.py +0 -0
- {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/results.py +0 -0
- {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/stacked_did.py +0 -0
- {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/synthetic_control.py +0 -0
- {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/synthetic_control_results.py +0 -0
- {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/trop_global.py +0 -0
- {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/trop_local.py +0 -0
- {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/trop_results.py +0 -0
- {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/two_stage_aggregation.py +0 -0
- {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/two_stage_bootstrap.py +0 -0
- {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/visualization/__init__.py +0 -0
- {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/visualization/_common.py +0 -0
- {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/visualization/_continuous.py +0 -0
- {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/visualization/_diagnostic.py +0 -0
- {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/visualization/_power.py +0 -0
- {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/visualization/_synthetic.py +0 -0
- {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/wooldridge_results.py +0 -0
- {diff_diff-3.10.0 → diff_diff-3.11.1}/rust/build.rs +0 -0
- {diff_diff-3.10.0 → diff_diff-3.11.1}/rust/src/alloc_profile.rs +0 -0
- {diff_diff-3.10.0 → diff_diff-3.11.1}/rust/src/batched_solve.rs +0 -0
- {diff_diff-3.10.0 → diff_diff-3.11.1}/rust/src/bootstrap.rs +0 -0
- {diff_diff-3.10.0 → diff_diff-3.11.1}/rust/src/demean.rs +0 -0
- {diff_diff-3.10.0 → diff_diff-3.11.1}/rust/src/lib.rs +0 -0
- {diff_diff-3.10.0 → diff_diff-3.11.1}/rust/src/linalg.rs +0 -0
- {diff_diff-3.10.0 → diff_diff-3.11.1}/rust/src/trop.rs +0 -0
- {diff_diff-3.10.0 → diff_diff-3.11.1}/rust/src/weights.rs +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: diff-diff
|
|
3
|
-
Version: 3.
|
|
3
|
+
Version: 3.11.1
|
|
4
4
|
Classifier: Development Status :: 5 - Production/Stable
|
|
5
5
|
Classifier: Intended Audience :: Science/Research
|
|
6
6
|
Classifier: Operating System :: OS Independent
|
|
@@ -21,8 +21,8 @@ Requires-Dist: pytest>=7.0 ; extra == 'dev'
|
|
|
21
21
|
Requires-Dist: pytest-xdist>=3.0 ; extra == 'dev'
|
|
22
22
|
Requires-Dist: pytest-cov>=4.0 ; extra == 'dev'
|
|
23
23
|
Requires-Dist: black==26.5.1 ; extra == 'dev'
|
|
24
|
-
Requires-Dist: ruff==0.16.
|
|
25
|
-
Requires-Dist: mypy==2.3.
|
|
24
|
+
Requires-Dist: ruff==0.16.4 ; extra == 'dev'
|
|
25
|
+
Requires-Dist: mypy==2.3.1 ; extra == 'dev'
|
|
26
26
|
Requires-Dist: maturin>=1.4,<2.0 ; extra == 'dev'
|
|
27
27
|
Requires-Dist: matplotlib>=3.5 ; extra == 'dev'
|
|
28
28
|
Requires-Dist: nbmake>=1.5 ; extra == 'dev'
|
|
@@ -176,6 +176,7 @@ Full guide: `diff_diff.get_llm_guide("practitioner")`.
|
|
|
176
176
|
- [LPDiD](https://diff-diff.readthedocs.io/en/stable/api/lpdid.html) - Dube, Girardi, Jorda & Taylor (2025) Local Projections DiD: per-horizon long-difference event study on clean controls (no negative weighting), variance- or equally-weighted ATT, for absorbing or non-absorbing (reversible) treatment
|
|
177
177
|
- [ChangesInChanges](https://diff-diff.readthedocs.io/en/stable/api/changes_in_changes.html) - Athey & Imbens (2006) nonlinear/distributional DiD for the 2x2 design: full counterfactual distribution and quantile treatment effects via CDF transformation, plus the QDiD comparison estimator via `method="qdid"`; bootstrap inference; R qte parity. Alias `CiC`
|
|
178
178
|
- [LWDiD](https://diff-diff.readthedocs.io/en/stable/api/lwdid.html) - Lee & Wooldridge (2025, 2026) rolling-transformation DiD: unit-specific demean/detrend converts panel to cross-section, staggered adoption, `estimation_method` in `reg`/`ipw`/`dr`/`psm` (the papers' RA/IPW/IPWRA plus propensity-score matching), exact small-N inference on the classical collapsed regression
|
|
179
|
+
- [DMLDiD](https://diff-diff.readthedocs.io/en/stable/api/dml_did.html) - Chang (2020) double/debiased machine learning DiD: staggered ATT(g,t) with cross-fitted ML nuisance learners (DML2) and Neyman-orthogonal scores, for flexible/high-dimensional covariate adjustment under conditional parallel trends; panel or declared repeated cross sections (`panel=False`); survey/cluster support on both lanes
|
|
179
180
|
- [BaconDecomposition](https://diff-diff.readthedocs.io/en/stable/api/bacon.html) - Goodman-Bacon (2021) decomposition for diagnosing TWFE bias in staggered settings
|
|
180
181
|
|
|
181
182
|
## Diagnostics & Sensitivity
|
|
@@ -121,6 +121,7 @@ Full guide: `diff_diff.get_llm_guide("practitioner")`.
|
|
|
121
121
|
- [LPDiD](https://diff-diff.readthedocs.io/en/stable/api/lpdid.html) - Dube, Girardi, Jorda & Taylor (2025) Local Projections DiD: per-horizon long-difference event study on clean controls (no negative weighting), variance- or equally-weighted ATT, for absorbing or non-absorbing (reversible) treatment
|
|
122
122
|
- [ChangesInChanges](https://diff-diff.readthedocs.io/en/stable/api/changes_in_changes.html) - Athey & Imbens (2006) nonlinear/distributional DiD for the 2x2 design: full counterfactual distribution and quantile treatment effects via CDF transformation, plus the QDiD comparison estimator via `method="qdid"`; bootstrap inference; R qte parity. Alias `CiC`
|
|
123
123
|
- [LWDiD](https://diff-diff.readthedocs.io/en/stable/api/lwdid.html) - Lee & Wooldridge (2025, 2026) rolling-transformation DiD: unit-specific demean/detrend converts panel to cross-section, staggered adoption, `estimation_method` in `reg`/`ipw`/`dr`/`psm` (the papers' RA/IPW/IPWRA plus propensity-score matching), exact small-N inference on the classical collapsed regression
|
|
124
|
+
- [DMLDiD](https://diff-diff.readthedocs.io/en/stable/api/dml_did.html) - Chang (2020) double/debiased machine learning DiD: staggered ATT(g,t) with cross-fitted ML nuisance learners (DML2) and Neyman-orthogonal scores, for flexible/high-dimensional covariate adjustment under conditional parallel trends; panel or declared repeated cross sections (`panel=False`); survey/cluster support on both lanes
|
|
124
125
|
- [BaconDecomposition](https://diff-diff.readthedocs.io/en/stable/api/bacon.html) - Goodman-Bacon (2021) decomposition for diagnosing TWFE bias in staggered settings
|
|
125
126
|
|
|
126
127
|
## Diagnostics & Sensitivity
|
|
@@ -32,6 +32,7 @@ from diff_diff._backend import (
|
|
|
32
32
|
_rust_solve_ols,
|
|
33
33
|
)
|
|
34
34
|
from diff_diff._guides_api import get_llm_guide
|
|
35
|
+
from diff_diff._learners import SieveLearner
|
|
35
36
|
from diff_diff.agent_workflow import agent_workflow
|
|
36
37
|
from diff_diff.aggregation import (
|
|
37
38
|
AggregationResult,
|
|
@@ -95,6 +96,8 @@ from diff_diff.diagnostics import (
|
|
|
95
96
|
run_all_placebo_tests,
|
|
96
97
|
run_placebo_test,
|
|
97
98
|
)
|
|
99
|
+
from diff_diff.dml_did import DMLDiD
|
|
100
|
+
from diff_diff.dml_did_results import DMLDiDResults
|
|
98
101
|
from diff_diff.efficient_did import (
|
|
99
102
|
EDiDBootstrapResults,
|
|
100
103
|
EfficientDiD,
|
|
@@ -368,7 +371,7 @@ def __getattr__(name: str) -> _Any:
|
|
|
368
371
|
raise AttributeError(f"module 'diff_diff' has no attribute {name!r}")
|
|
369
372
|
|
|
370
373
|
|
|
371
|
-
__version__ = "3.
|
|
374
|
+
__version__ = "3.11.1"
|
|
372
375
|
__all__ = [
|
|
373
376
|
# Estimators
|
|
374
377
|
"DifferenceInDifferences",
|
|
@@ -464,6 +467,10 @@ __all__ = [
|
|
|
464
467
|
# LWDiD (Lee & Wooldridge rolling transformation DiD)
|
|
465
468
|
"LWDiD",
|
|
466
469
|
"LWDiDResults",
|
|
470
|
+
# DMLDiD (Chang 2020 double/debiased ML DiD)
|
|
471
|
+
"DMLDiD",
|
|
472
|
+
"DMLDiDResults",
|
|
473
|
+
"SieveLearner",
|
|
467
474
|
# Visualization
|
|
468
475
|
"plot_bacon",
|
|
469
476
|
"plot_event_study",
|
|
@@ -0,0 +1,606 @@
|
|
|
1
|
+
"""Unit-level K-fold cross-fitting for DML nuisance estimation (private infra).
|
|
2
|
+
|
|
3
|
+
Fold assignment is deterministic and REPLAYABLE: ``assign_folds`` captures the
|
|
4
|
+
generator's bit-generator state BY VALUE before any draw (the
|
|
5
|
+
``aggregation.BootstrapReplaySpec`` discipline), so ``FoldAssignment.replay()``
|
|
6
|
+
reproduces the exact assignment from the stored state alone. Assignment
|
|
7
|
+
members are UNITS when ``cluster_ids is None`` and CLUSTERS otherwise (all
|
|
8
|
+
units of a cluster share a fold); balance and non-emptiness guarantees hold at
|
|
9
|
+
the member level.
|
|
10
|
+
|
|
11
|
+
``cross_fit_predict`` produces out-of-fold nuisance predictions for EVERY
|
|
12
|
+
unit: for each fold k the learner is fit on ``train_mask(k) & fit_mask`` and
|
|
13
|
+
predicts all units in fold k. Each fold fits a DEEP COPY of the user's
|
|
14
|
+
(never-fit) learner template, so no state — nested estimators and container
|
|
15
|
+
parameters included — can carry across folds; an un-deep-copyable learner is
|
|
16
|
+
reused with a loud warning under the fit-reset contract (see
|
|
17
|
+
``diff_diff._learners``).
|
|
18
|
+
|
|
19
|
+
Exception semantics (determinate):
|
|
20
|
+
|
|
21
|
+
- ``assign_folds`` and ``cross_fit_predict``'s OWN argument validation raise
|
|
22
|
+
plain ``ValueError`` — except a wrong-typed ``rng`` (not an
|
|
23
|
+
``np.random.Generator``), which raises ``TypeError`` per Python convention.
|
|
24
|
+
- Fold-time degeneracy raises ``DegenerateFoldError`` (a ``ValueError``
|
|
25
|
+
subclass): cheap universal pre-checks (empty fit set, zero positive weight,
|
|
26
|
+
single-class labels for classifiers) raise it directly; any ``ValueError``
|
|
27
|
+
from the learner's ``fit``/``predict``/``predict_proba`` or from prediction
|
|
28
|
+
validation is re-raised as ``DegenerateFoldError`` chained via ``raise ...
|
|
29
|
+
from exc`` with the fold index and the underlying message quoted verbatim
|
|
30
|
+
(no solver-specific threshold enumeration, no masking — the original
|
|
31
|
+
traceback is preserved). Non-``ValueError`` exceptions propagate untouched.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
import copy
|
|
35
|
+
import pickle
|
|
36
|
+
import warnings
|
|
37
|
+
from dataclasses import dataclass, field
|
|
38
|
+
from typing import Any, Dict, Iterator, Literal, Optional, Tuple, cast, overload
|
|
39
|
+
|
|
40
|
+
import numpy as np
|
|
41
|
+
import pandas as pd
|
|
42
|
+
|
|
43
|
+
from diff_diff._learners import (
|
|
44
|
+
ClassifierLearner,
|
|
45
|
+
RegressorLearner,
|
|
46
|
+
_validate_predictions,
|
|
47
|
+
validate_learner,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
__all__ = [
|
|
51
|
+
"DegenerateFoldError",
|
|
52
|
+
"FoldAssignment",
|
|
53
|
+
"CrossFitResult",
|
|
54
|
+
"assign_folds",
|
|
55
|
+
"cross_fit_predict",
|
|
56
|
+
]
|
|
57
|
+
|
|
58
|
+
_LOG_LOSS_CLIP = 1e-15
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _fresh_learner(learner: Any) -> Any:
|
|
62
|
+
"""Per-fold learner isolation: a deep copy of the (never-fit) template.
|
|
63
|
+
|
|
64
|
+
``copy.deepcopy`` of the user's template gives every fold a fully
|
|
65
|
+
independent learner — nested estimators, estimators inside lists/dicts,
|
|
66
|
+
accumulators, and warm-start state included — so no state (and therefore
|
|
67
|
+
no data from a previous complement, which includes the current evaluation
|
|
68
|
+
fold) can carry across folds. This is strictly stronger than
|
|
69
|
+
get_params-based reconstruction (which shares any estimator stored inside
|
|
70
|
+
a container parameter). The template itself is never fit. A copy FAILURE
|
|
71
|
+
is never silent: the instance is reused with a loud ``UserWarning`` naming
|
|
72
|
+
the learner and the fit-reset assumption now being relied on
|
|
73
|
+
(no-silent-failures rule).
|
|
74
|
+
"""
|
|
75
|
+
try:
|
|
76
|
+
return copy.deepcopy(learner)
|
|
77
|
+
except Exception as exc: # noqa: BLE001 - loud fallback, never silent
|
|
78
|
+
# Exception CLASS only, never the message: a foreign learner's
|
|
79
|
+
# __deepcopy__ error text can embed credentials/paths/data excerpts,
|
|
80
|
+
# and this warning lands in notebook/CI logs (the same boundary as
|
|
81
|
+
# DMLDiD's persisted-diagnostics sanitization).
|
|
82
|
+
warnings.warn(
|
|
83
|
+
f"cross_fit_predict: could not deep-copy the "
|
|
84
|
+
f"{type(learner).__name__} template for this fold "
|
|
85
|
+
f"({type(exc).__name__}); "
|
|
86
|
+
"REUSING the same instance and relying on its fit-reset behavior. "
|
|
87
|
+
"A warm-start/stateful learner in this situation can leak data "
|
|
88
|
+
"across folds.",
|
|
89
|
+
UserWarning,
|
|
90
|
+
stacklevel=3,
|
|
91
|
+
)
|
|
92
|
+
return learner
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _unique_or_raise(arr: np.ndarray, name: str, **kwargs: Any) -> Any:
|
|
96
|
+
"""np.unique with mixed-type object labels surfaced as a targeted ValueError."""
|
|
97
|
+
try:
|
|
98
|
+
return np.unique(arr, **kwargs)
|
|
99
|
+
except TypeError as exc:
|
|
100
|
+
raise ValueError(
|
|
101
|
+
f"{name} contains non-comparable mixed-type labels ({exc}); "
|
|
102
|
+
"use one consistent label type"
|
|
103
|
+
) from exc
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class DegenerateFoldError(ValueError):
|
|
107
|
+
"""A fold's fit subset is unusable (empty, degenerate, or the learner
|
|
108
|
+
rejected it). The message names the fold index, the counts that made it
|
|
109
|
+
degenerate, and a remedy."""
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@dataclass(frozen=True, eq=False)
|
|
113
|
+
class FoldAssignment:
|
|
114
|
+
"""Replayable unit-level fold assignment.
|
|
115
|
+
|
|
116
|
+
``eq=False``: ndarray fields make the auto-generated ``__eq__``/``__hash__``
|
|
117
|
+
unusable; compare field-wise in tests. Holds only ndarrays/ints/str/state
|
|
118
|
+
dict — picklable, and never holds learner objects.
|
|
119
|
+
"""
|
|
120
|
+
|
|
121
|
+
n_folds: int
|
|
122
|
+
n_units: int
|
|
123
|
+
fold_ids: np.ndarray # (n_units,) int64 in [0, n_folds)
|
|
124
|
+
bitgen_state: Dict[str, Any] # deep-copied BY VALUE before any draw
|
|
125
|
+
bitgen_name: str # bit-generator class name, for replay reconstruction
|
|
126
|
+
stratify_labels: Optional[np.ndarray] = None
|
|
127
|
+
cluster_ids: Optional[np.ndarray] = None
|
|
128
|
+
# Construction-time snapshot of bitgen_state; replay() reads this so later
|
|
129
|
+
# in-place mutation of the dict cannot change replayed fold ids.
|
|
130
|
+
_state_blob: bytes = field(init=False, repr=False, default=b"")
|
|
131
|
+
|
|
132
|
+
def __post_init__(self) -> None:
|
|
133
|
+
# Enforce the invariants every consumer relies on (cross_fit_predict
|
|
134
|
+
# writes oof slots per test fold, so an out-of-range/missing fold id
|
|
135
|
+
# would leave np.empty memory exposed) and freeze the arrays so a
|
|
136
|
+
# hand-mutated assignment cannot silently violate the replay contract.
|
|
137
|
+
fold_ids = np.asarray(self.fold_ids)
|
|
138
|
+
if fold_ids.ndim != 1 or fold_ids.shape[0] != self.n_units:
|
|
139
|
+
raise ValueError(
|
|
140
|
+
f"fold_ids must be 1-dimensional with n_units={self.n_units} "
|
|
141
|
+
f"entries, got shape {fold_ids.shape}"
|
|
142
|
+
)
|
|
143
|
+
if not np.issubdtype(fold_ids.dtype, np.integer):
|
|
144
|
+
raise ValueError(f"fold_ids must be an integer array, got dtype {fold_ids.dtype}")
|
|
145
|
+
if self.n_folds < 2:
|
|
146
|
+
raise ValueError(f"n_folds must be >= 2, got {self.n_folds}")
|
|
147
|
+
if np.any(fold_ids < 0) or np.any(fold_ids >= self.n_folds):
|
|
148
|
+
raise ValueError(
|
|
149
|
+
f"fold_ids values must lie in [0, {self.n_folds}), got range "
|
|
150
|
+
f"[{fold_ids.min()}, {fold_ids.max()}]"
|
|
151
|
+
)
|
|
152
|
+
counts = np.bincount(fold_ids, minlength=self.n_folds)
|
|
153
|
+
if np.any(counts == 0):
|
|
154
|
+
raise ValueError(
|
|
155
|
+
f"fold(s) {np.flatnonzero(counts == 0).tolist()} own no units; "
|
|
156
|
+
"every fold must be non-empty"
|
|
157
|
+
)
|
|
158
|
+
frozen = fold_ids.astype(np.int64, copy=True)
|
|
159
|
+
frozen.setflags(write=False)
|
|
160
|
+
object.__setattr__(self, "fold_ids", frozen)
|
|
161
|
+
# Snapshot the bit-generator state at construction: replay() reads this
|
|
162
|
+
# serialized copy, so mutating the (necessarily mutable) bitgen_state
|
|
163
|
+
# dict afterwards cannot silently change replayed fold ids. The public
|
|
164
|
+
# field itself is also detached from the caller's reference.
|
|
165
|
+
object.__setattr__(self, "bitgen_state", copy.deepcopy(self.bitgen_state))
|
|
166
|
+
object.__setattr__(self, "_state_blob", pickle.dumps(self.bitgen_state))
|
|
167
|
+
for attr in ("stratify_labels", "cluster_ids"):
|
|
168
|
+
val = getattr(self, attr)
|
|
169
|
+
if val is not None:
|
|
170
|
+
val = np.asarray(val).copy()
|
|
171
|
+
if val.ndim != 1 or val.shape[0] != self.n_units:
|
|
172
|
+
raise ValueError(
|
|
173
|
+
f"{attr} must be 1-dimensional with n_units="
|
|
174
|
+
f"{self.n_units} entries, got shape {val.shape}"
|
|
175
|
+
)
|
|
176
|
+
if np.any(pd.isna(val)):
|
|
177
|
+
raise ValueError(f"{attr} contains missing values (None/NaN/NA)")
|
|
178
|
+
val.setflags(write=False)
|
|
179
|
+
object.__setattr__(self, attr, val)
|
|
180
|
+
if self.cluster_ids is not None:
|
|
181
|
+
# Cluster cohesion: every cluster's units share one fold (the
|
|
182
|
+
# invariant cluster-level cross-fitting relies on — a split
|
|
183
|
+
# cluster leaks information between train and test).
|
|
184
|
+
_, first_idx, inv = np.unique(self.cluster_ids, return_index=True, return_inverse=True)
|
|
185
|
+
cluster_fold = frozen[first_idx]
|
|
186
|
+
mismatch = frozen != cluster_fold[inv]
|
|
187
|
+
if np.any(mismatch):
|
|
188
|
+
bad_cluster = self.cluster_ids[np.flatnonzero(mismatch)[0]]
|
|
189
|
+
raise ValueError(
|
|
190
|
+
f"cluster {bad_cluster!r} spans multiple folds; all units "
|
|
191
|
+
"of a cluster must share one fold"
|
|
192
|
+
)
|
|
193
|
+
if self.stratify_labels is not None:
|
|
194
|
+
strat_first = self.stratify_labels[first_idx]
|
|
195
|
+
if not np.all(self.stratify_labels == strat_first[inv]):
|
|
196
|
+
raise ValueError(
|
|
197
|
+
"stratify_labels vary within a cluster; pass cluster-level "
|
|
198
|
+
"stratum labels"
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
def test_mask(self, k: int) -> np.ndarray:
|
|
202
|
+
return self.fold_ids == k
|
|
203
|
+
|
|
204
|
+
def train_mask(self, k: int) -> np.ndarray:
|
|
205
|
+
return self.fold_ids != k
|
|
206
|
+
|
|
207
|
+
def iter_folds(self) -> Iterator[Tuple[int, np.ndarray, np.ndarray]]:
|
|
208
|
+
"""Yield ``(k, train_indices, test_indices)`` as int64 index arrays."""
|
|
209
|
+
for k in range(self.n_folds):
|
|
210
|
+
test = np.flatnonzero(self.fold_ids == k)
|
|
211
|
+
train = np.flatnonzero(self.fold_ids != k)
|
|
212
|
+
yield k, train, test
|
|
213
|
+
|
|
214
|
+
def counts(self) -> np.ndarray:
|
|
215
|
+
"""MEMBER counts per fold: units unclustered, clusters clustered."""
|
|
216
|
+
if self.cluster_ids is None:
|
|
217
|
+
return np.bincount(self.fold_ids, minlength=self.n_folds)
|
|
218
|
+
counts = np.zeros(self.n_folds, dtype=np.int64)
|
|
219
|
+
_, first_idx = np.unique(self.cluster_ids, return_index=True)
|
|
220
|
+
for i in first_idx:
|
|
221
|
+
counts[self.fold_ids[i]] += 1
|
|
222
|
+
return counts
|
|
223
|
+
|
|
224
|
+
def replay(self) -> "FoldAssignment":
|
|
225
|
+
"""Rebuild the rng from the stored state and re-run the assignment.
|
|
226
|
+
|
|
227
|
+
The result must be identical to this assignment (by-value replay
|
|
228
|
+
discipline). Raises a targeted ``ValueError`` for an unknown
|
|
229
|
+
bit-generator name rather than mis-seeding.
|
|
230
|
+
"""
|
|
231
|
+
bitgen_cls = getattr(np.random, self.bitgen_name, None)
|
|
232
|
+
if bitgen_cls is None:
|
|
233
|
+
raise ValueError(
|
|
234
|
+
f"FoldAssignment.replay: unknown bit generator {self.bitgen_name!r}; "
|
|
235
|
+
"cannot reconstruct the generator from the stored state"
|
|
236
|
+
)
|
|
237
|
+
rng = np.random.Generator(bitgen_cls())
|
|
238
|
+
rng.bit_generator.state = pickle.loads(self._state_blob)
|
|
239
|
+
return assign_folds(
|
|
240
|
+
self.n_units,
|
|
241
|
+
self.n_folds,
|
|
242
|
+
rng=rng,
|
|
243
|
+
stratify=self.stratify_labels,
|
|
244
|
+
cluster_ids=self.cluster_ids,
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
@dataclass(frozen=True, eq=False)
|
|
249
|
+
class CrossFitResult:
|
|
250
|
+
"""Out-of-fold predictions + per-fold diagnostics (picklable only).
|
|
251
|
+
|
|
252
|
+
``fold_losses[k]`` is the out-of-fold loss on fold k — MSE for
|
|
253
|
+
``predict``, log-loss for ``predict_proba`` — computed as a
|
|
254
|
+
test-fold-``sample_weight``-weighted mean when weights were supplied
|
|
255
|
+
(unweighted otherwise). A test fold with zero total weight gets
|
|
256
|
+
``fold_losses[k] = NaN`` (documented sentinel — a diagnostic, never an
|
|
257
|
+
error). Log-loss clips probabilities to ``[1e-15, 1 - 1e-15]`` for the
|
|
258
|
+
LOSS ONLY; ``oof_predictions`` are the learner's outputs, unclipped.
|
|
259
|
+
"""
|
|
260
|
+
|
|
261
|
+
oof_predictions: np.ndarray # (n_units,)
|
|
262
|
+
fold_losses: np.ndarray # (n_folds,)
|
|
263
|
+
n_fit_per_fold: np.ndarray # (n_folds,) int64
|
|
264
|
+
diagnostics: Dict[str, Any] = field(default_factory=dict)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _validate_per_unit_array(
|
|
268
|
+
arr: Optional[np.ndarray],
|
|
269
|
+
n_units: int,
|
|
270
|
+
name: str,
|
|
271
|
+
*,
|
|
272
|
+
allow_none_values: bool = False,
|
|
273
|
+
) -> Optional[np.ndarray]:
|
|
274
|
+
if arr is None:
|
|
275
|
+
return None
|
|
276
|
+
out = np.asarray(arr)
|
|
277
|
+
if out.ndim != 1:
|
|
278
|
+
raise ValueError(
|
|
279
|
+
f"{name} must be 1-dimensional with one entry per unit, got ndim={out.ndim} "
|
|
280
|
+
"(a column vector like (n, 1) is not accepted)"
|
|
281
|
+
)
|
|
282
|
+
if out.shape[0] != n_units:
|
|
283
|
+
raise ValueError(f"{name} has length {out.shape[0]}, expected n_units={n_units}")
|
|
284
|
+
if not allow_none_values:
|
|
285
|
+
# Dtype-independent missing-value detection (None, np.nan inside an
|
|
286
|
+
# object array, pd.NA, NaT, ...) — a missing label reaching np.unique
|
|
287
|
+
# can silently split one "missing" cluster into several members.
|
|
288
|
+
missing = pd.isna(out)
|
|
289
|
+
if np.any(missing):
|
|
290
|
+
raise ValueError(
|
|
291
|
+
f"{name} contains missing values (None/NaN/NA) at "
|
|
292
|
+
f"{int(np.sum(missing))} position(s); labels must be complete"
|
|
293
|
+
)
|
|
294
|
+
return out
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def assign_folds(
|
|
298
|
+
n_units: int,
|
|
299
|
+
n_folds: int,
|
|
300
|
+
*,
|
|
301
|
+
rng: np.random.Generator,
|
|
302
|
+
stratify: Optional[np.ndarray] = None,
|
|
303
|
+
cluster_ids: Optional[np.ndarray] = None,
|
|
304
|
+
) -> FoldAssignment:
|
|
305
|
+
"""Deterministic, replayable member-level K-fold assignment.
|
|
306
|
+
|
|
307
|
+
Members are units (``cluster_ids is None``) or clusters (all units of a
|
|
308
|
+
cluster share a fold; ``stratify`` must then be constant within each
|
|
309
|
+
cluster). Within each stratum (sorted-unique order) members are permuted
|
|
310
|
+
and dealt with a single global fold cursor that carries across strata, so
|
|
311
|
+
member counts per fold differ by <= 1 globally and per stratum, and no
|
|
312
|
+
fold is member-empty whenever ``n_folds <= n_members``. Under clustering,
|
|
313
|
+
unit-level fold sizes are NOT balanced (clusters differ in size).
|
|
314
|
+
"""
|
|
315
|
+
if not isinstance(rng, np.random.Generator):
|
|
316
|
+
raise TypeError(
|
|
317
|
+
f"rng must be a numpy.random.Generator, got {type(rng).__name__} "
|
|
318
|
+
"(legacy RandomState is not supported — its state is not replayable "
|
|
319
|
+
"through this module's by-value discipline)"
|
|
320
|
+
)
|
|
321
|
+
for name, val in (("n_units", n_units), ("n_folds", n_folds)):
|
|
322
|
+
if isinstance(val, bool) or not isinstance(val, (int, np.integer)):
|
|
323
|
+
raise ValueError(f"{name} must be an integer, got {val!r}")
|
|
324
|
+
if n_units < 1:
|
|
325
|
+
raise ValueError(f"n_units must be >= 1, got {n_units}")
|
|
326
|
+
if n_folds < 2:
|
|
327
|
+
raise ValueError(f"n_folds must be >= 2, got {n_folds}")
|
|
328
|
+
|
|
329
|
+
stratify = _validate_per_unit_array(stratify, n_units, "stratify")
|
|
330
|
+
cluster_ids = _validate_per_unit_array(cluster_ids, n_units, "cluster_ids")
|
|
331
|
+
|
|
332
|
+
# Capture the state BY VALUE before any draw (replay discipline).
|
|
333
|
+
bitgen_state = copy.deepcopy(rng.bit_generator.state)
|
|
334
|
+
bitgen_name = type(rng.bit_generator).__name__
|
|
335
|
+
|
|
336
|
+
if cluster_ids is None:
|
|
337
|
+
member_of_unit = np.arange(n_units)
|
|
338
|
+
n_members = n_units
|
|
339
|
+
member_strata = stratify
|
|
340
|
+
else:
|
|
341
|
+
unique_clusters, first_idx, member_of_unit = _unique_or_raise(
|
|
342
|
+
cluster_ids, "cluster_ids", return_index=True, return_inverse=True
|
|
343
|
+
)
|
|
344
|
+
n_members = unique_clusters.shape[0]
|
|
345
|
+
if stratify is not None:
|
|
346
|
+
# One label per cluster (its first unit), then one vectorized
|
|
347
|
+
# consistency check over all units — O(n_units), not
|
|
348
|
+
# O(n_clusters * n_units).
|
|
349
|
+
member_strata = stratify[first_idx]
|
|
350
|
+
consistent = stratify == member_strata[member_of_unit]
|
|
351
|
+
if not np.all(consistent):
|
|
352
|
+
bad = member_of_unit[np.flatnonzero(~consistent)[0]]
|
|
353
|
+
raise ValueError(
|
|
354
|
+
f"stratify varies within cluster {unique_clusters[bad]!r}; "
|
|
355
|
+
"pass cluster-level stratum labels (one label per cluster, "
|
|
356
|
+
"broadcast to its units)"
|
|
357
|
+
)
|
|
358
|
+
else:
|
|
359
|
+
member_strata = None
|
|
360
|
+
|
|
361
|
+
if n_folds > n_members:
|
|
362
|
+
kind = "units" if cluster_ids is None else "clusters"
|
|
363
|
+
raise ValueError(
|
|
364
|
+
f"n_folds={n_folds} exceeds the number of assignment members "
|
|
365
|
+
f"({n_members} {kind}); reduce n_folds"
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
if member_strata is None:
|
|
369
|
+
strata_values = [None]
|
|
370
|
+
members_by_stratum = [np.arange(n_members)]
|
|
371
|
+
else:
|
|
372
|
+
strata_values = list(_unique_or_raise(member_strata, "stratify"))
|
|
373
|
+
members_by_stratum = [np.flatnonzero(member_strata == s) for s in strata_values]
|
|
374
|
+
for s, members in zip(strata_values, members_by_stratum):
|
|
375
|
+
if members.shape[0] < 2:
|
|
376
|
+
raise ValueError(
|
|
377
|
+
f"stratum {s!r} has only {members.shape[0]} member(s); every "
|
|
378
|
+
"stratum needs >= 2 members — a singleton stratum vanishes from "
|
|
379
|
+
"the complement of its own fold (merge strata or drop stratify)"
|
|
380
|
+
)
|
|
381
|
+
|
|
382
|
+
member_fold = np.empty(n_members, dtype=np.int64)
|
|
383
|
+
cursor = 0 # single global fold cursor carrying across strata
|
|
384
|
+
for members in members_by_stratum:
|
|
385
|
+
permuted = members[rng.permutation(members.shape[0])]
|
|
386
|
+
for m in permuted:
|
|
387
|
+
member_fold[m] = cursor % n_folds
|
|
388
|
+
cursor += 1
|
|
389
|
+
|
|
390
|
+
fold_ids = member_fold[member_of_unit]
|
|
391
|
+
|
|
392
|
+
# Defensive post-assignment check: every fold must own >= 1 member.
|
|
393
|
+
member_counts = np.bincount(member_fold, minlength=n_folds)
|
|
394
|
+
assert np.all(member_counts > 0), "internal error: empty fold after assignment"
|
|
395
|
+
|
|
396
|
+
return FoldAssignment(
|
|
397
|
+
n_folds=n_folds,
|
|
398
|
+
n_units=n_units,
|
|
399
|
+
fold_ids=fold_ids,
|
|
400
|
+
bitgen_state=bitgen_state,
|
|
401
|
+
bitgen_name=bitgen_name,
|
|
402
|
+
stratify_labels=None if stratify is None else stratify.copy(),
|
|
403
|
+
cluster_ids=None if cluster_ids is None else cluster_ids.copy(),
|
|
404
|
+
)
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
@overload
|
|
408
|
+
def cross_fit_predict(
|
|
409
|
+
learner: RegressorLearner,
|
|
410
|
+
X: np.ndarray,
|
|
411
|
+
y: np.ndarray,
|
|
412
|
+
folds: FoldAssignment,
|
|
413
|
+
*,
|
|
414
|
+
fit_mask: Optional[np.ndarray] = ...,
|
|
415
|
+
predict_method: Literal["predict"] = ...,
|
|
416
|
+
sample_weight: Optional[np.ndarray] = ...,
|
|
417
|
+
context_label: str = ...,
|
|
418
|
+
) -> CrossFitResult: ...
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
@overload
|
|
422
|
+
def cross_fit_predict(
|
|
423
|
+
learner: ClassifierLearner,
|
|
424
|
+
X: np.ndarray,
|
|
425
|
+
y: np.ndarray,
|
|
426
|
+
folds: FoldAssignment,
|
|
427
|
+
*,
|
|
428
|
+
fit_mask: Optional[np.ndarray] = ...,
|
|
429
|
+
predict_method: Literal["predict_proba"],
|
|
430
|
+
sample_weight: Optional[np.ndarray] = ...,
|
|
431
|
+
context_label: str = ...,
|
|
432
|
+
) -> CrossFitResult: ...
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def cross_fit_predict(
|
|
436
|
+
learner: object,
|
|
437
|
+
X: np.ndarray,
|
|
438
|
+
y: np.ndarray,
|
|
439
|
+
folds: FoldAssignment,
|
|
440
|
+
*,
|
|
441
|
+
fit_mask: Optional[np.ndarray] = None,
|
|
442
|
+
predict_method: str = "predict",
|
|
443
|
+
sample_weight: Optional[np.ndarray] = None,
|
|
444
|
+
context_label: str = "",
|
|
445
|
+
) -> CrossFitResult:
|
|
446
|
+
"""Out-of-fold predictions for every unit.
|
|
447
|
+
|
|
448
|
+
Per fold k: fit the learner on ``train_mask(k) & fit_mask`` (e.g. the
|
|
449
|
+
untreated units), predict ALL units in fold k. ``context_label``
|
|
450
|
+
(e.g. the nuisance name or (g,t) cell) is prefixed into every
|
|
451
|
+
``DegenerateFoldError`` message to identify WHICH cross-fit failed.
|
|
452
|
+
"""
|
|
453
|
+
n_units = folds.n_units
|
|
454
|
+
label = f"{context_label}: " if context_label else ""
|
|
455
|
+
|
|
456
|
+
if predict_method not in ("predict", "predict_proba"):
|
|
457
|
+
raise ValueError(
|
|
458
|
+
f"predict_method must be 'predict' or 'predict_proba', got {predict_method!r}"
|
|
459
|
+
)
|
|
460
|
+
kind = "regressor" if predict_method == "predict" else "classifier"
|
|
461
|
+
validate_learner(learner, kind=kind, param_name="learner")
|
|
462
|
+
|
|
463
|
+
X = np.asarray(X, dtype=np.float64)
|
|
464
|
+
y = np.asarray(y, dtype=np.float64)
|
|
465
|
+
if X.ndim != 2 or X.shape[0] != n_units:
|
|
466
|
+
raise ValueError(
|
|
467
|
+
f"X must be 2-dimensional with folds.n_units={n_units} rows, " f"got shape {X.shape}"
|
|
468
|
+
)
|
|
469
|
+
if y.ndim != 1 or y.shape[0] != n_units:
|
|
470
|
+
raise ValueError(
|
|
471
|
+
f"y must be 1-dimensional with folds.n_units={n_units} entries, " f"got shape {y.shape}"
|
|
472
|
+
)
|
|
473
|
+
if not np.isfinite(X).all():
|
|
474
|
+
raise ValueError("X contains NaN or Inf values")
|
|
475
|
+
if not np.isfinite(y).all():
|
|
476
|
+
raise ValueError("y contains NaN or Inf values")
|
|
477
|
+
|
|
478
|
+
if fit_mask is None:
|
|
479
|
+
fit_mask_arr = np.ones(n_units, dtype=bool)
|
|
480
|
+
else:
|
|
481
|
+
raw_mask = np.asarray(fit_mask)
|
|
482
|
+
if raw_mask.ndim != 1 or raw_mask.shape[0] != n_units:
|
|
483
|
+
raise ValueError(
|
|
484
|
+
f"fit_mask must be 1-dimensional with {n_units} entries, "
|
|
485
|
+
f"got shape {raw_mask.shape}"
|
|
486
|
+
)
|
|
487
|
+
if raw_mask.dtype != np.bool_:
|
|
488
|
+
raise ValueError(
|
|
489
|
+
f"fit_mask must be a boolean array, got dtype {raw_mask.dtype} "
|
|
490
|
+
"(an int/float mask would silently select the wrong rows)"
|
|
491
|
+
)
|
|
492
|
+
fit_mask_arr = raw_mask
|
|
493
|
+
|
|
494
|
+
if sample_weight is not None:
|
|
495
|
+
sample_weight = np.asarray(sample_weight, dtype=np.float64)
|
|
496
|
+
if sample_weight.ndim != 1:
|
|
497
|
+
raise ValueError(
|
|
498
|
+
f"sample_weight must be 1-dimensional, got ndim={sample_weight.ndim} "
|
|
499
|
+
"(a column vector like (n, 1) is not accepted)"
|
|
500
|
+
)
|
|
501
|
+
if sample_weight.shape[0] != n_units:
|
|
502
|
+
raise ValueError(
|
|
503
|
+
f"sample_weight has length {sample_weight.shape[0]}, expected {n_units}"
|
|
504
|
+
)
|
|
505
|
+
if not np.isfinite(sample_weight).all():
|
|
506
|
+
raise ValueError("sample_weight contains NaN or Inf values")
|
|
507
|
+
if np.any(sample_weight < 0):
|
|
508
|
+
raise ValueError("sample_weight must be non-negative")
|
|
509
|
+
|
|
510
|
+
if predict_method == "predict_proba" and not np.all((y == 0.0) | (y == 1.0)):
|
|
511
|
+
raise ValueError(
|
|
512
|
+
"y must be strictly binary 0/1 for predict_method='predict_proba' "
|
|
513
|
+
"(the logit solver silently saturates on other encodings)"
|
|
514
|
+
)
|
|
515
|
+
|
|
516
|
+
oof = np.empty(n_units, dtype=np.float64)
|
|
517
|
+
fold_losses = np.empty(folds.n_folds, dtype=np.float64)
|
|
518
|
+
n_fit_per_fold = np.empty(folds.n_folds, dtype=np.int64)
|
|
519
|
+
|
|
520
|
+
for k, train_idx, test_idx in folds.iter_folds():
|
|
521
|
+
fit_idx = train_idx[fit_mask_arr[train_idx]]
|
|
522
|
+
n_fit = fit_idx.shape[0]
|
|
523
|
+
n_fit_per_fold[k] = n_fit
|
|
524
|
+
w_fit = None if sample_weight is None else sample_weight[fit_idx]
|
|
525
|
+
|
|
526
|
+
# (a) Universal cheap pre-checks -> DegenerateFoldError directly.
|
|
527
|
+
if n_fit == 0:
|
|
528
|
+
raise DegenerateFoldError(
|
|
529
|
+
f"{label}fold {k}: the fit subset is empty (train size "
|
|
530
|
+
f"{train_idx.shape[0]}, fit_mask keeps 0). Reduce n_folds, widen "
|
|
531
|
+
"fit_mask, or check the stratify labels."
|
|
532
|
+
)
|
|
533
|
+
if w_fit is not None and not np.any(w_fit > 0):
|
|
534
|
+
raise DegenerateFoldError(
|
|
535
|
+
f"{label}fold {k}: all {n_fit} fit rows have zero sample_weight. "
|
|
536
|
+
"Reduce n_folds or check the weights."
|
|
537
|
+
)
|
|
538
|
+
if predict_method == "predict_proba":
|
|
539
|
+
labels = y[fit_idx] if w_fit is None else y[fit_idx][w_fit > 0]
|
|
540
|
+
if np.unique(labels).shape[0] < 2:
|
|
541
|
+
raise DegenerateFoldError(
|
|
542
|
+
f"{label}fold {k}: the fit subset has a single "
|
|
543
|
+
f"{'positive-weight ' if w_fit is not None else ''}class "
|
|
544
|
+
f"(n_fit={n_fit}). A classifier needs both classes in every "
|
|
545
|
+
"fold's complement; reduce n_folds or stratify by the label."
|
|
546
|
+
)
|
|
547
|
+
|
|
548
|
+
# (b) Learner errors during the fold -> DegenerateFoldError, chained.
|
|
549
|
+
try:
|
|
550
|
+
fold_learner = _fresh_learner(learner)
|
|
551
|
+
# Unweighted path calls fit(X, y) WITHOUT the keyword: the
|
|
552
|
+
# advertised duck-typed contract is fit/predict(_proba), so a
|
|
553
|
+
# learner whose fit signature is only (X, y) must work when no
|
|
554
|
+
# weights are in play. sample_weight= is passed only on
|
|
555
|
+
# genuinely weighted paths, where an unsupported signature
|
|
556
|
+
# raises TypeError — a caller protocol violation that PROPAGATES
|
|
557
|
+
# (the DegenerateFoldError wrapper below catches ValueError
|
|
558
|
+
# only; fold-data degeneracy, not signature bugs).
|
|
559
|
+
fit_kwargs = {} if w_fit is None else {"sample_weight": w_fit}
|
|
560
|
+
if kind == "regressor":
|
|
561
|
+
reg = cast(RegressorLearner, fold_learner)
|
|
562
|
+
reg.fit(X[fit_idx], y[fit_idx], **fit_kwargs)
|
|
563
|
+
raw_pred = reg.predict(X[test_idx])
|
|
564
|
+
else:
|
|
565
|
+
clf = cast(ClassifierLearner, fold_learner)
|
|
566
|
+
clf.fit(X[fit_idx], y[fit_idx], **fit_kwargs)
|
|
567
|
+
raw_pred = clf.predict_proba(X[test_idx])
|
|
568
|
+
pred = _validate_predictions(
|
|
569
|
+
raw_pred,
|
|
570
|
+
test_idx.shape[0],
|
|
571
|
+
kind=kind,
|
|
572
|
+
context=f"{label}fold {k}",
|
|
573
|
+
classes=(getattr(fold_learner, "classes_", None) if kind == "classifier" else None),
|
|
574
|
+
)
|
|
575
|
+
except DegenerateFoldError:
|
|
576
|
+
raise
|
|
577
|
+
except ValueError as exc:
|
|
578
|
+
raise DegenerateFoldError(
|
|
579
|
+
f"{label}learner error in fold {k}: {exc}; the fold's fit subset "
|
|
580
|
+
f"has n={n_fit}"
|
|
581
|
+
+ (f", n_pos_weight={int(np.sum(w_fit > 0))}" if w_fit is not None else "")
|
|
582
|
+
+ ". Reduce n_folds, widen fit_mask, or check the fold's data."
|
|
583
|
+
) from exc
|
|
584
|
+
|
|
585
|
+
oof[test_idx] = pred
|
|
586
|
+
|
|
587
|
+
# Out-of-fold loss (diagnostic; NaN sentinel for zero-weight folds).
|
|
588
|
+
w_test = None if sample_weight is None else sample_weight[test_idx]
|
|
589
|
+
if predict_method == "predict":
|
|
590
|
+
errs = (y[test_idx] - pred) ** 2
|
|
591
|
+
else:
|
|
592
|
+
p_clip = np.clip(pred, _LOG_LOSS_CLIP, 1.0 - _LOG_LOSS_CLIP)
|
|
593
|
+
errs = -(y[test_idx] * np.log(p_clip) + (1.0 - y[test_idx]) * np.log(1.0 - p_clip))
|
|
594
|
+
if w_test is None:
|
|
595
|
+
fold_losses[k] = float(np.mean(errs))
|
|
596
|
+
elif np.sum(w_test) > 0:
|
|
597
|
+
fold_losses[k] = float(np.sum(w_test * errs) / np.sum(w_test))
|
|
598
|
+
else:
|
|
599
|
+
fold_losses[k] = np.nan
|
|
600
|
+
|
|
601
|
+
return CrossFitResult(
|
|
602
|
+
oof_predictions=oof,
|
|
603
|
+
fold_losses=fold_losses,
|
|
604
|
+
n_fit_per_fold=n_fit_per_fold,
|
|
605
|
+
diagnostics={"predict_method": predict_method, "context_label": context_label},
|
|
606
|
+
)
|