closed-loop-default-detection 0.1.0__tar.gz → 0.2.0__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 (43) hide show
  1. {closed_loop_default_detection-0.1.0 → closed_loop_default_detection-0.2.0}/PKG-INFO +99 -20
  2. {closed_loop_default_detection-0.1.0 → closed_loop_default_detection-0.2.0}/README.md +97 -18
  3. {closed_loop_default_detection-0.1.0 → closed_loop_default_detection-0.2.0}/pyproject.toml +2 -2
  4. {closed_loop_default_detection-0.1.0 → closed_loop_default_detection-0.2.0}/src/cldd/__init__.py +15 -3
  5. {closed_loop_default_detection-0.1.0 → closed_loop_default_detection-0.2.0}/src/cldd/config.py +14 -2
  6. {closed_loop_default_detection-0.1.0 → closed_loop_default_detection-0.2.0}/src/cldd/correctors.py +68 -8
  7. {closed_loop_default_detection-0.1.0 → closed_loop_default_detection-0.2.0}/src/cldd/diagnostics.py +1 -1
  8. closed_loop_default_detection-0.2.0/src/cldd/emp.py +341 -0
  9. {closed_loop_default_detection-0.1.0 → closed_loop_default_detection-0.2.0}/src/cldd/eval_default.py +93 -12
  10. {closed_loop_default_detection-0.1.0 → closed_loop_default_detection-0.2.0}/src/cldd/feedback.py +2 -2
  11. {closed_loop_default_detection-0.1.0 → closed_loop_default_detection-0.2.0}/src/cldd/loop.py +11 -4
  12. {closed_loop_default_detection-0.1.0 → closed_loop_default_detection-0.2.0}/src/cldd/model_pd.py +1 -2
  13. {closed_loop_default_detection-0.1.0 → closed_loop_default_detection-0.2.0}/src/cldd/synthetic.py +5 -6
  14. {closed_loop_default_detection-0.1.0 → closed_loop_default_detection-0.2.0}/src/closed_loop_default_detection.egg-info/PKG-INFO +99 -20
  15. {closed_loop_default_detection-0.1.0 → closed_loop_default_detection-0.2.0}/src/closed_loop_default_detection.egg-info/SOURCES.txt +2 -0
  16. closed_loop_default_detection-0.2.0/tests/test_emp.py +430 -0
  17. closed_loop_default_detection-0.2.0/tests/test_eval_default.py +208 -0
  18. closed_loop_default_detection-0.2.0/tests/test_exploration.py +201 -0
  19. closed_loop_default_detection-0.1.0/tests/test_eval_default.py +0 -89
  20. closed_loop_default_detection-0.1.0/tests/test_exploration.py +0 -90
  21. {closed_loop_default_detection-0.1.0 → closed_loop_default_detection-0.2.0}/LICENSE +0 -0
  22. {closed_loop_default_detection-0.1.0 → closed_loop_default_detection-0.2.0}/setup.cfg +0 -0
  23. {closed_loop_default_detection-0.1.0 → closed_loop_default_detection-0.2.0}/src/cldd/counterfactual.py +0 -0
  24. {closed_loop_default_detection-0.1.0 → closed_loop_default_detection-0.2.0}/src/cldd/fidelity.py +0 -0
  25. {closed_loop_default_detection-0.1.0 → closed_loop_default_detection-0.2.0}/src/cldd/py.typed +0 -0
  26. {closed_loop_default_detection-0.1.0 → closed_loop_default_detection-0.2.0}/src/cldd/reject_inference.py +0 -0
  27. {closed_loop_default_detection-0.1.0 → closed_loop_default_detection-0.2.0}/src/cldd/scm.py +0 -0
  28. {closed_loop_default_detection-0.1.0 → closed_loop_default_detection-0.2.0}/src/closed_loop_default_detection.egg-info/dependency_links.txt +0 -0
  29. {closed_loop_default_detection-0.1.0 → closed_loop_default_detection-0.2.0}/src/closed_loop_default_detection.egg-info/requires.txt +0 -0
  30. {closed_loop_default_detection-0.1.0 → closed_loop_default_detection-0.2.0}/src/closed_loop_default_detection.egg-info/top_level.txt +0 -0
  31. {closed_loop_default_detection-0.1.0 → closed_loop_default_detection-0.2.0}/tests/test_correctors.py +0 -0
  32. {closed_loop_default_detection-0.1.0 → closed_loop_default_detection-0.2.0}/tests/test_counterfactual.py +0 -0
  33. {closed_loop_default_detection-0.1.0 → closed_loop_default_detection-0.2.0}/tests/test_diagnostics.py +0 -0
  34. {closed_loop_default_detection-0.1.0 → closed_loop_default_detection-0.2.0}/tests/test_feedback.py +0 -0
  35. {closed_loop_default_detection-0.1.0 → closed_loop_default_detection-0.2.0}/tests/test_fidelity.py +0 -0
  36. {closed_loop_default_detection-0.1.0 → closed_loop_default_detection-0.2.0}/tests/test_fidelity_report.py +0 -0
  37. {closed_loop_default_detection-0.1.0 → closed_loop_default_detection-0.2.0}/tests/test_loop.py +0 -0
  38. {closed_loop_default_detection-0.1.0 → closed_loop_default_detection-0.2.0}/tests/test_loop_scm.py +0 -0
  39. {closed_loop_default_detection-0.1.0 → closed_loop_default_detection-0.2.0}/tests/test_model_pd.py +0 -0
  40. {closed_loop_default_detection-0.1.0 → closed_loop_default_detection-0.2.0}/tests/test_reject_inference.py +0 -0
  41. {closed_loop_default_detection-0.1.0 → closed_loop_default_detection-0.2.0}/tests/test_scm_gating.py +0 -0
  42. {closed_loop_default_detection-0.1.0 → closed_loop_default_detection-0.2.0}/tests/test_sklearn_compat.py +0 -0
  43. {closed_loop_default_detection-0.1.0 → closed_loop_default_detection-0.2.0}/tests/test_synthetic.py +0 -0
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: closed-loop-default-detection
3
- Version: 0.1.0
4
- Summary: CLUE-style closed loop that measures selective-labels default detection on synthetic SMB lending cohorts and finds the PD model's operating frontier.
3
+ Version: 0.2.0
4
+ Summary: Closed loop that measures selective-labels default detection on synthetic SMB lending cohorts and finds the PD model's operating frontier.
5
5
  Author-email: Hossain Pazooki <hossain@pazooki.com>
6
6
  License-Expression: MIT
7
7
  Project-URL: Homepage, https://github.com/hossainpazooki/closed-loop-default-detection
@@ -55,7 +55,7 @@ hides labels the way real approval policies do, and grades every correction agai
55
55
 
56
56
  The loop escalates selection severity until correction fails and reports the **operating
57
57
  frontier** — the last severity at which declined-cohort calibration still holds (target
58
- ECE ≤ 0.10). From the committed runs (`artifacts/clue_frontier*.csv`, seed 42):
58
+ ECE ≤ 0.10). From the committed runs (`artifacts/loop_frontier*.csv`, seed 42):
59
59
 
60
60
  | Selection severity | 0.0 | 0.2 | 0.4 | 0.6 |
61
61
  |---|---|---|---|---|
@@ -63,17 +63,90 @@ ECE ≤ 0.10). From the committed runs (`artifacts/clue_frontier*.csv`, seed 42)
63
63
  | **IPW-corrected** (flat world) | 0.020 | 0.038 | **0.086 ✓** | **0.154 ✗** |
64
64
  | **IPW-corrected** (SCM world) | 0.036 | 0.038 | **0.097 ✓** | **0.244 ✗** |
65
65
 
66
- Both worlds land the frontier at **severity 0.4**, and the counterfactual deliverable breaks at
67
- the same boundary: across 25 seeds, g-computation cuts strong-propagation counterfactual MAE
68
- from 0.099 to 0.086 (−13.5%, positive on 24/25 seeds, Wilcoxon p = 1.5e-7) *inside* the
69
- frontier — and collapses to a negligible +0.0017 at full severity, where **no deployable
70
- advantage is claimed**. One cause explains both: selection through an **unobserved
71
- confounder**, which backdoor adjustment and IPW cannot fix. That single measured limit — not
72
- an unverifiable score — is the deliverable.
66
+ On **this seed** both worlds land the frontier at severity 0.4, and the counterfactual
67
+ deliverable breaks at the same boundary: across 25 seeds, g-computation cuts
68
+ strong-propagation counterfactual MAE from 0.099 to 0.086 (−13.5%, positive on 24/25 seeds,
69
+ Wilcoxon p = 1.5e-7) *inside* the frontier — and collapses to a negligible +0.0017 at full
70
+ severity, where **no deployable advantage is claimed**. One cause explains both: selection
71
+ through an **unobserved confounder**, which backdoor adjustment and IPW cannot fix. That
72
+ single measured limit — not an unverifiable score — is the deliverable.
73
+
74
+ ### The frontier is a distribution, not a point (v2)
75
+
76
+ A frontier quoted from one seed is a figure published without an error bar. `v0.2.0` runs the
77
+ loop across the full 25-seed set (`artifacts/frontier_sweep.csv`) — and **seed 42 turns out to
78
+ sit at the optimistic end**:
79
+
80
+ | World | min | median | max | seeds at 0.4 | seeds at 0.2 |
81
+ |---|---|---|---|---|---|
82
+ | flat | 0.2 | **0.4** | 0.4 | 15/25 | 10/25 |
83
+ | SCM | 0.2 | **0.2** | 0.4 | 11/25 | 14/25 |
84
+
85
+ In the SCM world the *majority of seeds fail one step earlier than the headline*: the median
86
+ frontier is **0.2**, and seed 42's 0.4 is a minority outcome (11/25). The honest statement is
87
+ that the operating frontier is **0.2–0.4 depending on the draw**, not a clean 0.4 — the
88
+ single-seed table above is a valid instance of it, not its center. Nothing about the
89
+ mechanism changes (the unobserved confounder still explains the failure); what changes is how
90
+ precisely the boundary can be quoted.
91
+
92
+ *Caveat on the sweep:* loop seed `s` consumes generator seeds `s..s+7`, and the 25-seed set has
93
+ gaps smaller than 8, so some runs share feature draws. No two runs duplicate a cohort, but the
94
+ 25 rows are not fully independent — the set is kept for comparability with the counterfactual
95
+ sweep that uses it.
73
96
 
74
97
  Reproduce the headline from committed evidence: `python scripts/paired_significance.py`.
75
98
  The full independent assessment (methodology, all numbers, what didn't hold) is the
76
- accompanying article, [`FABLE.md`](FABLE.md).
99
+ accompanying article, [`docs/assessment.md`](docs/assessment.md) — a **dated snapshot**, written
100
+ against the single-seed frontier and not retro-fitted with the distribution above.
101
+
102
+ ## Pricing the frontier (v2)
103
+
104
+ Calibration says *whether* the model is wrong on the declines; it does not say **what being
105
+ wrong costs**. v2 adds an expected-maximum-profit (EMP) reporting axis — computed from the
106
+ same in-process scores the loop already measures, and **never** a loop-control input (ECE
107
+ still decides pass/fail and the frontier). Two variants run side by side:
108
+
109
+ | Variant | Parameters | What it answers |
110
+ |---|---|---|
111
+ | `empc` — literature EMPC | Verbraken et al. (2014) prior: `p0=0.55, p1=0.10, ROI=0.2644` | benchmark-comparable: what the standard measure says |
112
+ | `emp_h` — harness-derived | this harness's own economics + **planted** default timing | what the loan structure actually pays |
113
+
114
+ **The two disagree — and the disagreement is the finding.** In the SCM world they move in
115
+ opposite directions as selection severity rises (from `artifacts/loop_frontier_scm.csv`,
116
+ declined subpopulation, seed 42):
117
+
118
+ | Selection severity | 0.0 | 0.2 | 0.4 | 0.6 |
119
+ |---|---|---|---|---|
120
+ | Literature `empc` (naive) | 0.0217 | 0.0331 | 0.0416 | **0.0420 ↑** |
121
+ | Harness `emp_h` (naive) | 0.0387 | 0.0297 | 0.0139 | **0.0024 ↓** |
122
+
123
+ The convenience prior **misprices this loan structure**: it assumes `ROI = 0.2644` where a
124
+ 60-day daily-ACH loan actually returns `0.0875` (3% origination fee + 5.75% term interest) —
125
+ a **3.0× overstatement** — and puts **55% of defaults at full recovery where the harness plants
126
+ 1.2%** (prior mean λ = 0.275 vs harness 0.419). Priced honestly, the profit on the declined
127
+ pool collapses to near-zero at the frontier; priced by the literature prior, it appears to
128
+ *grow*. Same model, same scores, opposite conclusion.
129
+
130
+ **Reading caveats — the boundary of what this measures:**
131
+
132
+ - **Raw EMP moves with world hardness.** A riskier declined pool changes EMP even for a
133
+ perfect model, so a cross-severity EMP delta is *not* pure model signal. Read the two
134
+ variants against each other at fixed severity, not the trend in isolation.
135
+ - **`emp_h` rests on unfitted timing.** `days_to_default` is planted but independent of
136
+ features and risk given default — spec-shaped, never validated against real recovery data.
137
+ So `emp_h` is a **verified experiment, not a verified result**: the arithmetic is exact, the
138
+ timing distribution is an assumption.
139
+ - **Post-term defaults are imputed.** ~22.5% of planted defaults land at day 90, past the
140
+ 60-day term, and the generator does not model their payment history; they are priced at the
141
+ cohort's mean in-term loss fraction (a stated convention, not measured truth).
142
+ - **`emp_h` is SCM-only.** The flat generator plants no timing, so its `emp_h` columns are
143
+ empty by design; only `empc` is reported there.
144
+
145
+ Exploration now carries a price tag too. From `artifacts/exploration_frontier.csv` (SCM,
146
+ seed 42, 10% budget, severity 0.6): the lever buys **157 labels for $439,578** — about
147
+ **$2,800 per label**, of which 56 are realized defaults. That is the cost of *identification*,
148
+ in dollars a lender can see, on the only lever that buys it rather than reweighting what is
149
+ already identified.
77
150
 
78
151
  ## Install
79
152
 
@@ -139,6 +212,7 @@ Everything is importable from top-level `cldd` (full reference: the [Sphinx docs
139
212
  | `SyntheticBorrowerGenerator`, `StructuralBorrowerGenerator` | the flat and fitted-SCM synthetic worlds |
140
213
  | `run_counterfactual_eval`, `GComputationEstimator` | counterfactual validator (g-computation vs naive conditioning) |
141
214
  | `FeedbackLoop` | model-in-the-loop selective-labels simulation |
215
+ | `empc_literature`, `emp_harness` | the two EMP variants — price the frontier in profit (reporting only, never loop control) |
142
216
  | `positivity_diagnostics` | observable regime/drift alarm — needs **no** declined-row labels |
143
217
  | `CalibratedPDClassifier` | the calibrated PD detector as a scikit-learn estimator |
144
218
  | `cldd.fidelity.run_fidelity_gate` | SCM-vs-real **marginal**-fidelity gate (univariate marginals only) |
@@ -163,7 +237,8 @@ scores = cross_val_score(CalibratedPDClassifier(random_state=42), X, y, scoring=
163
237
  Each driver runs without install (adds `src/` to the path) and writes to `artifacts/`:
164
238
 
165
239
  ```bash
166
- python scripts/run_clue.py # the closed loop → frontier table + plot (--generator scm for the SCM world)
240
+ python scripts/run_loop.py # the closed loop → frontier table + plot (--generator scm for the SCM world)
241
+ python scripts/run_frontier_sweep.py --quick # frontier distribution across the 25-seed set (drop --quick for all)
167
242
  python scripts/run_seed_sweep.py --quick # counterfactual certification (drop --quick for all seeds)
168
243
  python scripts/run_reject_inference.py # reject-inference levers vs the frontier
169
244
  python scripts/run_exploration_sweep.py # frontier vs exploration budget
@@ -173,7 +248,7 @@ python scripts/paired_significance.py # recompute the headline stat from
173
248
 
174
249
  ## Validation
175
250
 
176
- `pytest` — 123 tests, all synthetic, no real data needed. CI runs a pinned-repro job (exact
251
+ `pytest` — 149 tests, all synthetic, no real data needed. CI runs a pinned-repro job (exact
177
252
  pins), a cross-version/OS compat matrix, and a strict docs build. Six float-sensitive tests
178
253
  reproduce only under the pins in `requirements-dev.txt`; the optional marginal-fidelity gate
179
254
  compares the SCM against a **private** real dataset via `CLDD_DATA_DIR` and is the only thing
@@ -189,17 +264,21 @@ that needs it. Details, reproducibility, and troubleshooting:
189
264
  | [docs/configuration.md](docs/configuration.md) | every knob (`config.py`) and the one env var |
190
265
  | [docs/validation.md](docs/validation.md) | tests, gates, reproducibility, troubleshooting |
191
266
  | [docs/reject_inference.md](docs/reject_inference.md) | the four RI methods and their honest (modest) results |
192
- | [`FABLE.md`](FABLE.md) | **the accompanying article** — independent results & methodology assessment |
267
+ | [`docs/assessment.md`](docs/assessment.md) | **the accompanying article** — independent results & methodology assessment (dated snapshot) |
193
268
 
194
269
  Build locally: `pip install -e ".[docs]" && sphinx-build -b html -W docs docs/_build/html`.
195
270
 
196
271
  ## Status
197
272
 
198
- `0.1.0` **alpha** on [PyPI](https://pypi.org/project/closed-loop-default-detection/),
199
- changelog in [CHANGELOG.md](CHANGELOG.md). Shipped: the loop, both worlds, all levers, the
200
- fidelity gate, the sklearn estimator, CI on three gates. CLDD began as a validation harness
201
- for the Intuit TechWeek SMB Underwriting Challenge; it is not a submission and does not
202
- alter challenge files.
273
+ `0.2.0` **alpha** on [PyPI](https://pypi.org/project/closed-loop-default-detection/),
274
+ changelog in [CHANGELOG.md](CHANGELOG.md).
275
+
276
+ Shipped in 0.1.0: the loop, both synthetic worlds, all correction levers, the fidelity gate,
277
+ the sklearn estimator, CI on three gates. Added in 0.2.0: `cldd.emp` (both EMP variants),
278
+ priced exploration, the EMP-optimal cutoff, and the 25-seed frontier sweep.
279
+
280
+ CLDD began as a validation harness for the Intuit TechWeek SMB Underwriting Challenge; it is
281
+ not a submission and does not alter challenge files.
203
282
 
204
283
  ## Citation
205
284
 
@@ -211,7 +290,7 @@ Metadata in [`CITATION.cff`](CITATION.cff) (GitHub's "Cite this repository" read
211
290
  title = {{closed-loop-default-detection}: measuring selective-labels default
212
291
  detection and the PD model's operating frontier},
213
292
  year = {2026},
214
- version = {0.1.0},
293
+ version = {0.2.0},
215
294
  license = {MIT},
216
295
  url = {https://github.com/hossainpazooki/closed-loop-default-detection}
217
296
  }
@@ -21,7 +21,7 @@ hides labels the way real approval policies do, and grades every correction agai
21
21
 
22
22
  The loop escalates selection severity until correction fails and reports the **operating
23
23
  frontier** — the last severity at which declined-cohort calibration still holds (target
24
- ECE ≤ 0.10). From the committed runs (`artifacts/clue_frontier*.csv`, seed 42):
24
+ ECE ≤ 0.10). From the committed runs (`artifacts/loop_frontier*.csv`, seed 42):
25
25
 
26
26
  | Selection severity | 0.0 | 0.2 | 0.4 | 0.6 |
27
27
  |---|---|---|---|---|
@@ -29,17 +29,90 @@ ECE ≤ 0.10). From the committed runs (`artifacts/clue_frontier*.csv`, seed 42)
29
29
  | **IPW-corrected** (flat world) | 0.020 | 0.038 | **0.086 ✓** | **0.154 ✗** |
30
30
  | **IPW-corrected** (SCM world) | 0.036 | 0.038 | **0.097 ✓** | **0.244 ✗** |
31
31
 
32
- Both worlds land the frontier at **severity 0.4**, and the counterfactual deliverable breaks at
33
- the same boundary: across 25 seeds, g-computation cuts strong-propagation counterfactual MAE
34
- from 0.099 to 0.086 (−13.5%, positive on 24/25 seeds, Wilcoxon p = 1.5e-7) *inside* the
35
- frontier — and collapses to a negligible +0.0017 at full severity, where **no deployable
36
- advantage is claimed**. One cause explains both: selection through an **unobserved
37
- confounder**, which backdoor adjustment and IPW cannot fix. That single measured limit — not
38
- an unverifiable score — is the deliverable.
32
+ On **this seed** both worlds land the frontier at severity 0.4, and the counterfactual
33
+ deliverable breaks at the same boundary: across 25 seeds, g-computation cuts
34
+ strong-propagation counterfactual MAE from 0.099 to 0.086 (−13.5%, positive on 24/25 seeds,
35
+ Wilcoxon p = 1.5e-7) *inside* the frontier — and collapses to a negligible +0.0017 at full
36
+ severity, where **no deployable advantage is claimed**. One cause explains both: selection
37
+ through an **unobserved confounder**, which backdoor adjustment and IPW cannot fix. That
38
+ single measured limit — not an unverifiable score — is the deliverable.
39
+
40
+ ### The frontier is a distribution, not a point (v2)
41
+
42
+ A frontier quoted from one seed is a figure published without an error bar. `v0.2.0` runs the
43
+ loop across the full 25-seed set (`artifacts/frontier_sweep.csv`) — and **seed 42 turns out to
44
+ sit at the optimistic end**:
45
+
46
+ | World | min | median | max | seeds at 0.4 | seeds at 0.2 |
47
+ |---|---|---|---|---|---|
48
+ | flat | 0.2 | **0.4** | 0.4 | 15/25 | 10/25 |
49
+ | SCM | 0.2 | **0.2** | 0.4 | 11/25 | 14/25 |
50
+
51
+ In the SCM world the *majority of seeds fail one step earlier than the headline*: the median
52
+ frontier is **0.2**, and seed 42's 0.4 is a minority outcome (11/25). The honest statement is
53
+ that the operating frontier is **0.2–0.4 depending on the draw**, not a clean 0.4 — the
54
+ single-seed table above is a valid instance of it, not its center. Nothing about the
55
+ mechanism changes (the unobserved confounder still explains the failure); what changes is how
56
+ precisely the boundary can be quoted.
57
+
58
+ *Caveat on the sweep:* loop seed `s` consumes generator seeds `s..s+7`, and the 25-seed set has
59
+ gaps smaller than 8, so some runs share feature draws. No two runs duplicate a cohort, but the
60
+ 25 rows are not fully independent — the set is kept for comparability with the counterfactual
61
+ sweep that uses it.
39
62
 
40
63
  Reproduce the headline from committed evidence: `python scripts/paired_significance.py`.
41
64
  The full independent assessment (methodology, all numbers, what didn't hold) is the
42
- accompanying article, [`FABLE.md`](FABLE.md).
65
+ accompanying article, [`docs/assessment.md`](docs/assessment.md) — a **dated snapshot**, written
66
+ against the single-seed frontier and not retro-fitted with the distribution above.
67
+
68
+ ## Pricing the frontier (v2)
69
+
70
+ Calibration says *whether* the model is wrong on the declines; it does not say **what being
71
+ wrong costs**. v2 adds an expected-maximum-profit (EMP) reporting axis — computed from the
72
+ same in-process scores the loop already measures, and **never** a loop-control input (ECE
73
+ still decides pass/fail and the frontier). Two variants run side by side:
74
+
75
+ | Variant | Parameters | What it answers |
76
+ |---|---|---|
77
+ | `empc` — literature EMPC | Verbraken et al. (2014) prior: `p0=0.55, p1=0.10, ROI=0.2644` | benchmark-comparable: what the standard measure says |
78
+ | `emp_h` — harness-derived | this harness's own economics + **planted** default timing | what the loan structure actually pays |
79
+
80
+ **The two disagree — and the disagreement is the finding.** In the SCM world they move in
81
+ opposite directions as selection severity rises (from `artifacts/loop_frontier_scm.csv`,
82
+ declined subpopulation, seed 42):
83
+
84
+ | Selection severity | 0.0 | 0.2 | 0.4 | 0.6 |
85
+ |---|---|---|---|---|
86
+ | Literature `empc` (naive) | 0.0217 | 0.0331 | 0.0416 | **0.0420 ↑** |
87
+ | Harness `emp_h` (naive) | 0.0387 | 0.0297 | 0.0139 | **0.0024 ↓** |
88
+
89
+ The convenience prior **misprices this loan structure**: it assumes `ROI = 0.2644` where a
90
+ 60-day daily-ACH loan actually returns `0.0875` (3% origination fee + 5.75% term interest) —
91
+ a **3.0× overstatement** — and puts **55% of defaults at full recovery where the harness plants
92
+ 1.2%** (prior mean λ = 0.275 vs harness 0.419). Priced honestly, the profit on the declined
93
+ pool collapses to near-zero at the frontier; priced by the literature prior, it appears to
94
+ *grow*. Same model, same scores, opposite conclusion.
95
+
96
+ **Reading caveats — the boundary of what this measures:**
97
+
98
+ - **Raw EMP moves with world hardness.** A riskier declined pool changes EMP even for a
99
+ perfect model, so a cross-severity EMP delta is *not* pure model signal. Read the two
100
+ variants against each other at fixed severity, not the trend in isolation.
101
+ - **`emp_h` rests on unfitted timing.** `days_to_default` is planted but independent of
102
+ features and risk given default — spec-shaped, never validated against real recovery data.
103
+ So `emp_h` is a **verified experiment, not a verified result**: the arithmetic is exact, the
104
+ timing distribution is an assumption.
105
+ - **Post-term defaults are imputed.** ~22.5% of planted defaults land at day 90, past the
106
+ 60-day term, and the generator does not model their payment history; they are priced at the
107
+ cohort's mean in-term loss fraction (a stated convention, not measured truth).
108
+ - **`emp_h` is SCM-only.** The flat generator plants no timing, so its `emp_h` columns are
109
+ empty by design; only `empc` is reported there.
110
+
111
+ Exploration now carries a price tag too. From `artifacts/exploration_frontier.csv` (SCM,
112
+ seed 42, 10% budget, severity 0.6): the lever buys **157 labels for $439,578** — about
113
+ **$2,800 per label**, of which 56 are realized defaults. That is the cost of *identification*,
114
+ in dollars a lender can see, on the only lever that buys it rather than reweighting what is
115
+ already identified.
43
116
 
44
117
  ## Install
45
118
 
@@ -105,6 +178,7 @@ Everything is importable from top-level `cldd` (full reference: the [Sphinx docs
105
178
  | `SyntheticBorrowerGenerator`, `StructuralBorrowerGenerator` | the flat and fitted-SCM synthetic worlds |
106
179
  | `run_counterfactual_eval`, `GComputationEstimator` | counterfactual validator (g-computation vs naive conditioning) |
107
180
  | `FeedbackLoop` | model-in-the-loop selective-labels simulation |
181
+ | `empc_literature`, `emp_harness` | the two EMP variants — price the frontier in profit (reporting only, never loop control) |
108
182
  | `positivity_diagnostics` | observable regime/drift alarm — needs **no** declined-row labels |
109
183
  | `CalibratedPDClassifier` | the calibrated PD detector as a scikit-learn estimator |
110
184
  | `cldd.fidelity.run_fidelity_gate` | SCM-vs-real **marginal**-fidelity gate (univariate marginals only) |
@@ -129,7 +203,8 @@ scores = cross_val_score(CalibratedPDClassifier(random_state=42), X, y, scoring=
129
203
  Each driver runs without install (adds `src/` to the path) and writes to `artifacts/`:
130
204
 
131
205
  ```bash
132
- python scripts/run_clue.py # the closed loop → frontier table + plot (--generator scm for the SCM world)
206
+ python scripts/run_loop.py # the closed loop → frontier table + plot (--generator scm for the SCM world)
207
+ python scripts/run_frontier_sweep.py --quick # frontier distribution across the 25-seed set (drop --quick for all)
133
208
  python scripts/run_seed_sweep.py --quick # counterfactual certification (drop --quick for all seeds)
134
209
  python scripts/run_reject_inference.py # reject-inference levers vs the frontier
135
210
  python scripts/run_exploration_sweep.py # frontier vs exploration budget
@@ -139,7 +214,7 @@ python scripts/paired_significance.py # recompute the headline stat from
139
214
 
140
215
  ## Validation
141
216
 
142
- `pytest` — 123 tests, all synthetic, no real data needed. CI runs a pinned-repro job (exact
217
+ `pytest` — 149 tests, all synthetic, no real data needed. CI runs a pinned-repro job (exact
143
218
  pins), a cross-version/OS compat matrix, and a strict docs build. Six float-sensitive tests
144
219
  reproduce only under the pins in `requirements-dev.txt`; the optional marginal-fidelity gate
145
220
  compares the SCM against a **private** real dataset via `CLDD_DATA_DIR` and is the only thing
@@ -155,17 +230,21 @@ that needs it. Details, reproducibility, and troubleshooting:
155
230
  | [docs/configuration.md](docs/configuration.md) | every knob (`config.py`) and the one env var |
156
231
  | [docs/validation.md](docs/validation.md) | tests, gates, reproducibility, troubleshooting |
157
232
  | [docs/reject_inference.md](docs/reject_inference.md) | the four RI methods and their honest (modest) results |
158
- | [`FABLE.md`](FABLE.md) | **the accompanying article** — independent results & methodology assessment |
233
+ | [`docs/assessment.md`](docs/assessment.md) | **the accompanying article** — independent results & methodology assessment (dated snapshot) |
159
234
 
160
235
  Build locally: `pip install -e ".[docs]" && sphinx-build -b html -W docs docs/_build/html`.
161
236
 
162
237
  ## Status
163
238
 
164
- `0.1.0` **alpha** on [PyPI](https://pypi.org/project/closed-loop-default-detection/),
165
- changelog in [CHANGELOG.md](CHANGELOG.md). Shipped: the loop, both worlds, all levers, the
166
- fidelity gate, the sklearn estimator, CI on three gates. CLDD began as a validation harness
167
- for the Intuit TechWeek SMB Underwriting Challenge; it is not a submission and does not
168
- alter challenge files.
239
+ `0.2.0` **alpha** on [PyPI](https://pypi.org/project/closed-loop-default-detection/),
240
+ changelog in [CHANGELOG.md](CHANGELOG.md).
241
+
242
+ Shipped in 0.1.0: the loop, both synthetic worlds, all correction levers, the fidelity gate,
243
+ the sklearn estimator, CI on three gates. Added in 0.2.0: `cldd.emp` (both EMP variants),
244
+ priced exploration, the EMP-optimal cutoff, and the 25-seed frontier sweep.
245
+
246
+ CLDD began as a validation harness for the Intuit TechWeek SMB Underwriting Challenge; it is
247
+ not a submission and does not alter challenge files.
169
248
 
170
249
  ## Citation
171
250
 
@@ -177,7 +256,7 @@ Metadata in [`CITATION.cff`](CITATION.cff) (GitHub's "Cite this repository" read
177
256
  title = {{closed-loop-default-detection}: measuring selective-labels default
178
257
  detection and the PD model's operating frontier},
179
258
  year = {2026},
180
- version = {0.1.0},
259
+ version = {0.2.0},
181
260
  license = {MIT},
182
261
  url = {https://github.com/hossainpazooki/closed-loop-default-detection}
183
262
  }
@@ -4,8 +4,8 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "closed-loop-default-detection"
7
- version = "0.1.0"
8
- description = "CLUE-style closed loop that measures selective-labels default detection on synthetic SMB lending cohorts and finds the PD model's operating frontier."
7
+ version = "0.2.0"
8
+ description = "Closed loop that measures selective-labels default detection on synthetic SMB lending cohorts and finds the PD model's operating frontier."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
11
11
  license = "MIT"
@@ -1,7 +1,7 @@
1
- """Closed-loop default-rate detection — a CLUE-style harness for selective labels.
1
+ """Closed-loop default-rate detection — a selective-labels validation harness.
2
2
 
3
3
  generate (``synthetic``) -> measure (``eval_default``) -> improve/frontier
4
- (``loop``). See README.md for the CLUE mapping and how to run.
4
+ (``loop``). See README.md for how to run.
5
5
  """
6
6
 
7
7
  from __future__ import annotations
@@ -11,7 +11,7 @@ from importlib.metadata import PackageNotFoundError, version as _pkg_version
11
11
  try: # pyproject `version` is the single source of truth (read from installed metadata)
12
12
  __version__ = _pkg_version("closed-loop-default-detection")
13
13
  except PackageNotFoundError: # running from a source tree with no install
14
- __version__ = "0.1.0"
14
+ __version__ = "0.2.0"
15
15
 
16
16
  from .correctors import (
17
17
  CorrectionOutcome,
@@ -36,6 +36,13 @@ from .reject_inference import (
36
36
  ReclassificationCorrector,
37
37
  RejectInferenceCorrector,
38
38
  )
39
+ from .emp import (
40
+ EMPCParams,
41
+ EMPResult,
42
+ LoanEconomics,
43
+ emp_harness,
44
+ empc_literature,
45
+ )
39
46
  from .eval_default import PdDetectionResult, fit_observed_model, score_pd_detection
40
47
  from .model_pd import CalibratedPDClassifier, CalibratedPDModel
41
48
  from .feedback import FeedbackLoop, FeedbackResult, GenerationResult
@@ -87,6 +94,11 @@ __all__ = [
87
94
  "PdDetectionResult",
88
95
  "fit_observed_model",
89
96
  "score_pd_detection",
97
+ "EMPCParams",
98
+ "EMPResult",
99
+ "LoanEconomics",
100
+ "empc_literature",
101
+ "emp_harness",
90
102
  "CalibratedPDModel",
91
103
  "CalibratedPDClassifier",
92
104
  "CounterfactualResult",
@@ -24,8 +24,7 @@ RANDOM_SEED = 42
24
24
 
25
25
  # Disjoint train-cohort offset for the retrain lever. Large enough that train
26
26
  # seeds (RANDOM_SEED + TRAIN_SEED_OFFSET + iteration) never collide with measure
27
- # seeds (RANDOM_SEED + iteration). This is the no-leakage discipline ported from
28
- # upstream-label-correction/clue/loop.py.
27
+ # seeds (RANDOM_SEED + iteration).
29
28
  TRAIN_SEED_OFFSET = 1000
30
29
 
31
30
  # --------------------------------------------------------------------------- #
@@ -59,6 +58,19 @@ TARGET_DECLINED_ECE = 0.10
59
58
 
60
59
  DEFAULT_N_APPLICANTS = 4000
61
60
 
61
+ # --------------------------------------------------------------------------- #
62
+ # EMP reporting layer (cldd.emp) — v2 measurement axis, never loop control
63
+ # --------------------------------------------------------------------------- #
64
+ # Literature EMPC prior. Verified against the primary source before entry
65
+ # (2026-07-13 spike): Verbraken, Bravo, Weber & Baesens (2014), EJOR
66
+ # 238(2):505-513 — h(lambda) = p0*delta(0) + p1*delta(1) + (1-p0-p1)*U(0,1),
67
+ # constant ROI, computed over the ROC convex hull (paper Eqs. 13/15). The
68
+ # defaults are the benchmark-standard CRAN EMP::empCreditScoring values, so
69
+ # empc columns are comparable to the published literature.
70
+ EMPC_P0 = 0.55 # P(lambda = 0): full recovery
71
+ EMPC_P1 = 0.10 # P(lambda = 1): total loss
72
+ EMPC_ROI = 0.2644 # per-loan return on investment, fraction of principal
73
+
62
74
  # --------------------------------------------------------------------------- #
63
75
  # Observable positivity-diagnostic thresholds (cldd.diagnostics)
64
76
  # --------------------------------------------------------------------------- #
@@ -11,9 +11,9 @@ used to live as ``_measure_*`` methods on ``SelectiveLabelsLoop`` — identical
11
11
  numpy calls, RNG seeds, sklearn calls and ordering — so the loop's numeric
12
12
  output is unchanged.
13
13
 
14
- This module imports only ``config``, ``eval_default``, ``model_pd`` and numpy;
15
- it must NOT import ``loop`` (loop re-exports ``LeverMetrics`` from here, so the
16
- reverse dependency would be circular).
14
+ This module imports only ``config``, ``emp``, ``eval_default``, ``model_pd`` and
15
+ numpy; it must NOT import ``loop`` (loop re-exports ``LeverMetrics`` from here,
16
+ so the reverse dependency would be circular).
17
17
  """
18
18
 
19
19
  from __future__ import annotations
@@ -24,12 +24,16 @@ from dataclasses import dataclass, field
24
24
 
25
25
  import numpy as np
26
26
 
27
- from . import config, eval_default, model_pd
27
+ from . import config, emp, eval_default, model_pd
28
28
 
29
29
 
30
30
  @dataclass
31
31
  class LeverMetrics:
32
- """Declined-subpopulation focus + the all-population calibration for one lever."""
32
+ """Declined-subpopulation focus + the all-population calibration for one lever.
33
+
34
+ EMP fields (v2 reporting axis) mirror the existing declined_*/all_ece
35
+ pattern and are ``None`` where inapplicable (see ``SubgroupMetrics``).
36
+ """
33
37
 
34
38
  declined_ece: float
35
39
  declined_auc: float
@@ -38,6 +42,13 @@ class LeverMetrics:
38
42
  declined_base_rate: float
39
43
  declined_f1: float
40
44
  all_ece: float
45
+ declined_empc: float | None = None
46
+ declined_empc_fraction: float | None = None
47
+ declined_emp_h: float | None = None
48
+ declined_emp_h_fraction: float | None = None
49
+ declined_f1_emp_cutoff: float | None = None
50
+ all_empc: float | None = None
51
+ all_emp_h: float | None = None
41
52
 
42
53
  @classmethod
43
54
  def from_scored(cls, scored: dict) -> "LeverMetrics":
@@ -51,6 +62,13 @@ class LeverMetrics:
51
62
  declined_base_rate=d.base_rate,
52
63
  declined_f1=d.f1,
53
64
  all_ece=a.ece,
65
+ declined_empc=d.empc,
66
+ declined_empc_fraction=d.empc_fraction,
67
+ declined_emp_h=d.emp_h,
68
+ declined_emp_h_fraction=d.emp_h_fraction,
69
+ declined_f1_emp_cutoff=d.f1_emp_cutoff,
70
+ all_empc=a.empc,
71
+ all_emp_h=a.emp_h,
54
72
  )
55
73
 
56
74
 
@@ -76,7 +94,8 @@ class CorrectionOutcome:
76
94
  """A corrector's metrics plus its lever-specific extras.
77
95
 
78
96
  ``info`` is ``{}`` (naive/reweight), ``{"train_seed": int}`` (retrain), or
79
- ``{"n_explored": int, "explored_defaults": int}`` (explore).
97
+ ``{"n_explored": int, "explored_defaults": int, "exploration_cost": float}``
98
+ (explore).
80
99
  """
81
100
 
82
101
  metrics: LeverMetrics
@@ -160,7 +179,13 @@ class ExplorationCorrector(Corrector):
160
179
 
161
180
  X = cohort["features"].to_numpy(dtype=float)
162
181
  y = cohort["true_default"]
163
- weights = np.where(explored, 1.0 / ctx.exploration_rate, 1.0)
182
+ # ctx.exploration_rate == 0.0 guarantees explored is all-False (the
183
+ # comparison above never fires), so the inverse-propensity value here
184
+ # is never actually selected by np.where -- but it is still evaluated
185
+ # eagerly, so guard the division to avoid a ZeroDivisionError on that
186
+ # placeholder. No effect on any exploration_rate > 0 run.
187
+ inv_propensity = (1.0 / ctx.exploration_rate) if ctx.exploration_rate > 0.0 else 1.0
188
+ weights = np.where(explored, inv_propensity, 1.0)
164
189
  model = model_pd.train_pd_model(
165
190
  X[funded], y[funded], sample_weight=weights[funded], random_state=ctx.seed
166
191
  )
@@ -168,5 +193,40 @@ class ExplorationCorrector(Corrector):
168
193
  metrics = LeverMetrics.from_scored(scored)
169
194
  return CorrectionOutcome(
170
195
  metrics=metrics,
171
- info={"n_explored": int(explored.sum()), "explored_defaults": int(y[explored].sum())},
196
+ info={
197
+ "n_explored": int(explored.sum()),
198
+ "explored_defaults": int(y[explored].sum()),
199
+ "exploration_cost": float(_exploration_cost(cohort, y, explored)),
200
+ },
201
+ )
202
+
203
+
204
+ def _exploration_cost(cohort: dict, y: np.ndarray, explored: np.ndarray) -> float:
205
+ """Net dollar cost of the exploration budget: -(sum of realized profits on
206
+ the EXPLORED rows), priced by ``cldd.emp`` economics (spec sec. 4).
207
+
208
+ SCM cohorts (``'days_to_default'`` present) price each explored row by its
209
+ own planted default timing via :func:`cldd.emp.realized_profits` (which
210
+ also carries the decision-5 day-90 mean-body-lambda imputation). Flat
211
+ cohorts plant no timing, so explored rows are priced by the stated
212
+ conservative flat convention: defaulters at lambda = 1 (profit = -A),
213
+ goods at the full-term collections (profit = A*(f + r_term)) — computed
214
+ inline here since ``realized_profits`` has no ``default_day=None`` path.
215
+
216
+ Positive = the exploration budget cost money net. 0.0 when nothing was
217
+ explored (in particular, whenever exploration is off).
218
+ """
219
+ if not explored.any():
220
+ return 0.0
221
+ requested_amount = cohort["features"]["requested_amount"].to_numpy(dtype=float)
222
+ if "days_to_default" in cohort:
223
+ profits = emp.realized_profits(y, cohort["days_to_default"], requested_amount)
224
+ else:
225
+ economics = emp.LoanEconomics()
226
+ is_default = y.astype(bool)
227
+ profits = np.where(
228
+ is_default,
229
+ -requested_amount,
230
+ requested_amount * (economics.origination_fee_rate + economics.r_term),
172
231
  )
232
+ return -float(profits[explored].sum())
@@ -1,6 +1,6 @@
1
1
  """Deployable positivity/overlap diagnostics — the observable frontier signal.
2
2
 
3
- Lesson from the hackathon harness (FABLE.md): the operating frontier was measured
3
+ Lesson from the accompanying article (docs/assessment.md): the operating frontier was measured
4
4
  against *planted* truth (declined-cohort ECE), which a real lender can never
5
5
  compute — so the frontier, as measured, is not deployable knowledge. What a
6
6
  lender CAN compute is how pathological the selection itself looks: