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.
- {closed_loop_default_detection-0.1.0 → closed_loop_default_detection-0.2.0}/PKG-INFO +99 -20
- {closed_loop_default_detection-0.1.0 → closed_loop_default_detection-0.2.0}/README.md +97 -18
- {closed_loop_default_detection-0.1.0 → closed_loop_default_detection-0.2.0}/pyproject.toml +2 -2
- {closed_loop_default_detection-0.1.0 → closed_loop_default_detection-0.2.0}/src/cldd/__init__.py +15 -3
- {closed_loop_default_detection-0.1.0 → closed_loop_default_detection-0.2.0}/src/cldd/config.py +14 -2
- {closed_loop_default_detection-0.1.0 → closed_loop_default_detection-0.2.0}/src/cldd/correctors.py +68 -8
- {closed_loop_default_detection-0.1.0 → closed_loop_default_detection-0.2.0}/src/cldd/diagnostics.py +1 -1
- closed_loop_default_detection-0.2.0/src/cldd/emp.py +341 -0
- {closed_loop_default_detection-0.1.0 → closed_loop_default_detection-0.2.0}/src/cldd/eval_default.py +93 -12
- {closed_loop_default_detection-0.1.0 → closed_loop_default_detection-0.2.0}/src/cldd/feedback.py +2 -2
- {closed_loop_default_detection-0.1.0 → closed_loop_default_detection-0.2.0}/src/cldd/loop.py +11 -4
- {closed_loop_default_detection-0.1.0 → closed_loop_default_detection-0.2.0}/src/cldd/model_pd.py +1 -2
- {closed_loop_default_detection-0.1.0 → closed_loop_default_detection-0.2.0}/src/cldd/synthetic.py +5 -6
- {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
- {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
- closed_loop_default_detection-0.2.0/tests/test_emp.py +430 -0
- closed_loop_default_detection-0.2.0/tests/test_eval_default.py +208 -0
- closed_loop_default_detection-0.2.0/tests/test_exploration.py +201 -0
- closed_loop_default_detection-0.1.0/tests/test_eval_default.py +0 -89
- closed_loop_default_detection-0.1.0/tests/test_exploration.py +0 -90
- {closed_loop_default_detection-0.1.0 → closed_loop_default_detection-0.2.0}/LICENSE +0 -0
- {closed_loop_default_detection-0.1.0 → closed_loop_default_detection-0.2.0}/setup.cfg +0 -0
- {closed_loop_default_detection-0.1.0 → closed_loop_default_detection-0.2.0}/src/cldd/counterfactual.py +0 -0
- {closed_loop_default_detection-0.1.0 → closed_loop_default_detection-0.2.0}/src/cldd/fidelity.py +0 -0
- {closed_loop_default_detection-0.1.0 → closed_loop_default_detection-0.2.0}/src/cldd/py.typed +0 -0
- {closed_loop_default_detection-0.1.0 → closed_loop_default_detection-0.2.0}/src/cldd/reject_inference.py +0 -0
- {closed_loop_default_detection-0.1.0 → closed_loop_default_detection-0.2.0}/src/cldd/scm.py +0 -0
- {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
- {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
- {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
- {closed_loop_default_detection-0.1.0 → closed_loop_default_detection-0.2.0}/tests/test_correctors.py +0 -0
- {closed_loop_default_detection-0.1.0 → closed_loop_default_detection-0.2.0}/tests/test_counterfactual.py +0 -0
- {closed_loop_default_detection-0.1.0 → closed_loop_default_detection-0.2.0}/tests/test_diagnostics.py +0 -0
- {closed_loop_default_detection-0.1.0 → closed_loop_default_detection-0.2.0}/tests/test_feedback.py +0 -0
- {closed_loop_default_detection-0.1.0 → closed_loop_default_detection-0.2.0}/tests/test_fidelity.py +0 -0
- {closed_loop_default_detection-0.1.0 → closed_loop_default_detection-0.2.0}/tests/test_fidelity_report.py +0 -0
- {closed_loop_default_detection-0.1.0 → closed_loop_default_detection-0.2.0}/tests/test_loop.py +0 -0
- {closed_loop_default_detection-0.1.0 → closed_loop_default_detection-0.2.0}/tests/test_loop_scm.py +0 -0
- {closed_loop_default_detection-0.1.0 → closed_loop_default_detection-0.2.0}/tests/test_model_pd.py +0 -0
- {closed_loop_default_detection-0.1.0 → closed_loop_default_detection-0.2.0}/tests/test_reject_inference.py +0 -0
- {closed_loop_default_detection-0.1.0 → closed_loop_default_detection-0.2.0}/tests/test_scm_gating.py +0 -0
- {closed_loop_default_detection-0.1.0 → closed_loop_default_detection-0.2.0}/tests/test_sklearn_compat.py +0 -0
- {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.
|
|
4
|
-
Summary:
|
|
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/
|
|
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
|
-
|
|
67
|
-
the same boundary: across 25 seeds, g-computation cuts
|
|
68
|
-
from 0.099 to 0.086 (−13.5%, positive on 24/25 seeds,
|
|
69
|
-
frontier — and collapses to a negligible +0.0017 at full
|
|
70
|
-
advantage is claimed**. One cause explains both: selection
|
|
71
|
-
confounder**, which backdoor adjustment and IPW cannot fix. That
|
|
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, [`
|
|
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/
|
|
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` —
|
|
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
|
-
| [`
|
|
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.
|
|
199
|
-
changelog in [CHANGELOG.md](CHANGELOG.md).
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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.
|
|
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/
|
|
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
|
-
|
|
33
|
-
the same boundary: across 25 seeds, g-computation cuts
|
|
34
|
-
from 0.099 to 0.086 (−13.5%, positive on 24/25 seeds,
|
|
35
|
-
frontier — and collapses to a negligible +0.0017 at full
|
|
36
|
-
advantage is claimed**. One cause explains both: selection
|
|
37
|
-
confounder**, which backdoor adjustment and IPW cannot fix. That
|
|
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, [`
|
|
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/
|
|
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` —
|
|
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
|
-
| [`
|
|
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.
|
|
165
|
-
changelog in [CHANGELOG.md](CHANGELOG.md).
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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.
|
|
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.
|
|
8
|
-
description = "
|
|
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"
|
{closed_loop_default_detection-0.1.0 → closed_loop_default_detection-0.2.0}/src/cldd/__init__.py
RENAMED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
"""Closed-loop default-rate detection — a
|
|
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
|
|
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.
|
|
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",
|
{closed_loop_default_detection-0.1.0 → closed_loop_default_detection-0.2.0}/src/cldd/config.py
RENAMED
|
@@ -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).
|
|
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
|
# --------------------------------------------------------------------------- #
|
{closed_loop_default_detection-0.1.0 → closed_loop_default_detection-0.2.0}/src/cldd/correctors.py
RENAMED
|
@@ -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
|
|
15
|
-
it must NOT import ``loop`` (loop re-exports ``LeverMetrics`` from here,
|
|
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}``
|
|
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
|
-
|
|
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={
|
|
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())
|
{closed_loop_default_detection-0.1.0 → closed_loop_default_detection-0.2.0}/src/cldd/diagnostics.py
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"""Deployable positivity/overlap diagnostics — the observable frontier signal.
|
|
2
2
|
|
|
3
|
-
Lesson from the
|
|
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:
|