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.
Files changed (127) hide show
  1. {diff_diff-3.10.0 → diff_diff-3.11.1}/PKG-INFO +4 -3
  2. {diff_diff-3.10.0 → diff_diff-3.11.1}/README.md +1 -0
  3. {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/__init__.py +8 -1
  4. diff_diff-3.11.1/diff_diff/_crossfit.py +606 -0
  5. diff_diff-3.11.1/diff_diff/_dr_scores.py +506 -0
  6. diff_diff-3.11.1/diff_diff/_learners.py +562 -0
  7. {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/_reporting_helpers.py +61 -0
  8. {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/_staggered_triple_diff_engine.py +14 -2
  9. {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/aggregation.py +5 -4
  10. {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/bootstrap_chunking.py +1 -1
  11. {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/business_report.py +59 -5
  12. {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/chaisemartin_dhaultfoeuille.py +6 -5
  13. {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/chaisemartin_dhaultfoeuille_results.py +8 -4
  14. {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/continuous_did.py +106 -64
  15. {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/continuous_did_results.py +18 -2
  16. {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/diagnostic_report.py +70 -19
  17. diff_diff-3.11.1/diff_diff/dml_did.py +2417 -0
  18. diff_diff-3.11.1/diff_diff/dml_did_results.py +260 -0
  19. {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/efficient_did.py +39 -4
  20. {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/efficient_did_covariates.py +60 -10
  21. {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/efficient_did_results.py +33 -10
  22. {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/estimators.py +2 -2
  23. {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/guides/llms-autonomous.txt +19 -0
  24. {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/guides/llms-full.txt +35 -2
  25. {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/guides/llms-practitioner.txt +13 -1
  26. {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/guides/llms.txt +2 -1
  27. {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/had.py +19 -4
  28. {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/had_pretests.py +6 -1
  29. {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/honest_did.py +66 -86
  30. {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/imputation.py +13 -1
  31. {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/imputation_results.py +32 -10
  32. {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/linalg.py +270 -1
  33. {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/lpdid.py +1 -1
  34. {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/lwdid.py +5 -9
  35. {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/mmm.py +21 -1
  36. {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/practitioner.py +100 -0
  37. {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/pretrends.py +33 -16
  38. {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/results_base.py +37 -11
  39. {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/spillover.py +4 -1
  40. {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/stacked_did_results.py +8 -3
  41. {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/staggered.py +84 -27
  42. {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/staggered_aggregation.py +5 -4
  43. {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/staggered_bootstrap.py +14 -12
  44. {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/staggered_results.py +29 -11
  45. {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/staggered_triple_diff.py +2 -2
  46. {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/staggered_triple_diff_results.py +8 -3
  47. {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/sun_abraham.py +13 -8
  48. {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/survey.py +1 -1
  49. {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/synthetic_did.py +5 -0
  50. {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/triple_diff.py +11 -25
  51. {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/trop.py +4 -1
  52. {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/twfe.py +1 -1
  53. {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/two_stage.py +1 -1
  54. {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/two_stage_results.py +8 -3
  55. {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/utils.py +48 -4
  56. {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/visualization/_event_study.py +133 -18
  57. {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/visualization/_staggered.py +87 -16
  58. {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/wooldridge.py +4 -2
  59. {diff_diff-3.10.0 → diff_diff-3.11.1}/pyproject.toml +3 -3
  60. {diff_diff-3.10.0 → diff_diff-3.11.1}/rust/Cargo.lock +11 -11
  61. {diff_diff-3.10.0 → diff_diff-3.11.1}/rust/Cargo.toml +1 -1
  62. {diff_diff-3.10.0 → diff_diff-3.11.1}/LICENSE +0 -0
  63. {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/_backend.py +0 -0
  64. {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/_base.py +0 -0
  65. {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/_deprecation.py +0 -0
  66. {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/_guides_api.py +0 -0
  67. {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/_nprobust_port.py +0 -0
  68. {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/_rdrobust_port.py +0 -0
  69. {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/agent_workflow.py +0 -0
  70. {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/bacon.py +0 -0
  71. {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/balancing.py +0 -0
  72. {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/bootstrap_utils.py +0 -0
  73. {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/chaisemartin_dhaultfoeuille_bootstrap.py +0 -0
  74. {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/changes_in_changes.py +0 -0
  75. {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/changes_in_changes_results.py +0 -0
  76. {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/conformal.py +0 -0
  77. {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/conley.py +0 -0
  78. {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/continuous_did_aggregation.py +0 -0
  79. {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/continuous_did_bspline.py +0 -0
  80. {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/datasets.py +0 -0
  81. {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/diagnostics.py +0 -0
  82. {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/efficient_did_aggregation.py +0 -0
  83. {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/efficient_did_bootstrap.py +0 -0
  84. {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/efficient_did_weights.py +0 -0
  85. {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/guides/__init__.py +0 -0
  86. {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/imputation_aggregation.py +0 -0
  87. {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/imputation_bootstrap.py +0 -0
  88. {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/local_linear.py +0 -0
  89. {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/lpdid_results.py +0 -0
  90. {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/lwdid_randomization.py +0 -0
  91. {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/lwdid_results.py +0 -0
  92. {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/lwdid_sensitivity.py +0 -0
  93. {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/lwdid_staggered.py +0 -0
  94. {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/lwdid_visualization.py +0 -0
  95. {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/lwdid_wild_bootstrap.py +0 -0
  96. {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/power.py +0 -0
  97. {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/prep.py +0 -0
  98. {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/prep_dgp.py +0 -0
  99. {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/profile.py +0 -0
  100. {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/rdd.py +0 -0
  101. {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/rddensity.py +0 -0
  102. {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/rdplot.py +0 -0
  103. {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/results.py +0 -0
  104. {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/stacked_did.py +0 -0
  105. {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/synthetic_control.py +0 -0
  106. {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/synthetic_control_results.py +0 -0
  107. {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/trop_global.py +0 -0
  108. {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/trop_local.py +0 -0
  109. {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/trop_results.py +0 -0
  110. {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/two_stage_aggregation.py +0 -0
  111. {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/two_stage_bootstrap.py +0 -0
  112. {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/visualization/__init__.py +0 -0
  113. {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/visualization/_common.py +0 -0
  114. {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/visualization/_continuous.py +0 -0
  115. {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/visualization/_diagnostic.py +0 -0
  116. {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/visualization/_power.py +0 -0
  117. {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/visualization/_synthetic.py +0 -0
  118. {diff_diff-3.10.0 → diff_diff-3.11.1}/diff_diff/wooldridge_results.py +0 -0
  119. {diff_diff-3.10.0 → diff_diff-3.11.1}/rust/build.rs +0 -0
  120. {diff_diff-3.10.0 → diff_diff-3.11.1}/rust/src/alloc_profile.rs +0 -0
  121. {diff_diff-3.10.0 → diff_diff-3.11.1}/rust/src/batched_solve.rs +0 -0
  122. {diff_diff-3.10.0 → diff_diff-3.11.1}/rust/src/bootstrap.rs +0 -0
  123. {diff_diff-3.10.0 → diff_diff-3.11.1}/rust/src/demean.rs +0 -0
  124. {diff_diff-3.10.0 → diff_diff-3.11.1}/rust/src/lib.rs +0 -0
  125. {diff_diff-3.10.0 → diff_diff-3.11.1}/rust/src/linalg.rs +0 -0
  126. {diff_diff-3.10.0 → diff_diff-3.11.1}/rust/src/trop.rs +0 -0
  127. {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.10.0
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.3 ; extra == 'dev'
25
- Requires-Dist: mypy==2.3.0 ; extra == 'dev'
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.10.0"
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
+ )