closed-loop-default-detection 0.3.0__tar.gz → 0.4.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 (60) hide show
  1. {closed_loop_default_detection-0.3.0 → closed_loop_default_detection-0.4.0}/PKG-INFO +20 -142
  2. closed_loop_default_detection-0.4.0/README.md +169 -0
  3. {closed_loop_default_detection-0.3.0 → closed_loop_default_detection-0.4.0}/pyproject.toml +3 -3
  4. {closed_loop_default_detection-0.3.0 → closed_loop_default_detection-0.4.0}/src/cldd/__init__.py +4 -1
  5. {closed_loop_default_detection-0.3.0 → closed_loop_default_detection-0.4.0}/src/cldd/counterfactual.py +17 -1
  6. {closed_loop_default_detection-0.3.0 → closed_loop_default_detection-0.4.0}/src/cldd/emp.py +2 -2
  7. {closed_loop_default_detection-0.3.0 → closed_loop_default_detection-0.4.0}/src/cldd/feedback.py +1 -1
  8. {closed_loop_default_detection-0.3.0 → closed_loop_default_detection-0.4.0}/src/cldd/fidelity.py +26 -17
  9. {closed_loop_default_detection-0.3.0 → closed_loop_default_detection-0.4.0}/src/cldd/loop.py +13 -1
  10. {closed_loop_default_detection-0.3.0 → closed_loop_default_detection-0.4.0}/src/cldd/scm.py +28 -1
  11. closed_loop_default_detection-0.4.0/src/cldd/selection.py +169 -0
  12. {closed_loop_default_detection-0.3.0 → closed_loop_default_detection-0.4.0}/src/cldd/synthetic.py +26 -1
  13. {closed_loop_default_detection-0.3.0 → closed_loop_default_detection-0.4.0}/src/closed_loop_default_detection.egg-info/PKG-INFO +20 -142
  14. {closed_loop_default_detection-0.3.0 → closed_loop_default_detection-0.4.0}/src/closed_loop_default_detection.egg-info/SOURCES.txt +15 -0
  15. {closed_loop_default_detection-0.3.0 → closed_loop_default_detection-0.4.0}/src/closed_loop_default_detection.egg-info/requires.txt +1 -0
  16. closed_loop_default_detection-0.4.0/tests/test_artifact_integrity.py +67 -0
  17. closed_loop_default_detection-0.4.0/tests/test_doc_numbers.py +376 -0
  18. {closed_loop_default_detection-0.3.0 → closed_loop_default_detection-0.4.0}/tests/test_feedback_flags.py +1 -1
  19. {closed_loop_default_detection-0.3.0 → closed_loop_default_detection-0.4.0}/tests/test_fidelity.py +2 -0
  20. closed_loop_default_detection-0.4.0/tests/test_fidelity_default_dir.py +94 -0
  21. closed_loop_default_detection-0.4.0/tests/test_preregistration.py +78 -0
  22. closed_loop_default_detection-0.4.0/tests/test_readme_lean.py +43 -0
  23. closed_loop_default_detection-0.4.0/tests/test_segment_knobs.py +121 -0
  24. closed_loop_default_detection-0.4.0/tests/test_segment_stats.py +252 -0
  25. closed_loop_default_detection-0.4.0/tests/test_segment_sweep.py +114 -0
  26. closed_loop_default_detection-0.4.0/tests/test_selection.py +100 -0
  27. closed_loop_default_detection-0.4.0/tests/test_spaced_manifest.py +58 -0
  28. closed_loop_default_detection-0.4.0/tests/test_strength_replication.py +400 -0
  29. closed_loop_default_detection-0.4.0/tests/test_surface_knobs.py +118 -0
  30. closed_loop_default_detection-0.4.0/tests/test_surface_stats.py +121 -0
  31. closed_loop_default_detection-0.4.0/tests/test_surface_sweep.py +68 -0
  32. closed_loop_default_detection-0.3.0/README.md +0 -292
  33. {closed_loop_default_detection-0.3.0 → closed_loop_default_detection-0.4.0}/LICENSE +0 -0
  34. {closed_loop_default_detection-0.3.0 → closed_loop_default_detection-0.4.0}/setup.cfg +0 -0
  35. {closed_loop_default_detection-0.3.0 → closed_loop_default_detection-0.4.0}/src/cldd/config.py +0 -0
  36. {closed_loop_default_detection-0.3.0 → closed_loop_default_detection-0.4.0}/src/cldd/correctors.py +0 -0
  37. {closed_loop_default_detection-0.3.0 → closed_loop_default_detection-0.4.0}/src/cldd/diagnostics.py +0 -0
  38. {closed_loop_default_detection-0.3.0 → closed_loop_default_detection-0.4.0}/src/cldd/eval_default.py +0 -0
  39. {closed_loop_default_detection-0.3.0 → closed_loop_default_detection-0.4.0}/src/cldd/model_pd.py +0 -0
  40. {closed_loop_default_detection-0.3.0 → closed_loop_default_detection-0.4.0}/src/cldd/py.typed +0 -0
  41. {closed_loop_default_detection-0.3.0 → closed_loop_default_detection-0.4.0}/src/cldd/reject_inference.py +0 -0
  42. {closed_loop_default_detection-0.3.0 → closed_loop_default_detection-0.4.0}/src/closed_loop_default_detection.egg-info/dependency_links.txt +0 -0
  43. {closed_loop_default_detection-0.3.0 → closed_loop_default_detection-0.4.0}/src/closed_loop_default_detection.egg-info/top_level.txt +0 -0
  44. {closed_loop_default_detection-0.3.0 → closed_loop_default_detection-0.4.0}/tests/test_book_profit.py +0 -0
  45. {closed_loop_default_detection-0.3.0 → closed_loop_default_detection-0.4.0}/tests/test_correctors.py +0 -0
  46. {closed_loop_default_detection-0.3.0 → closed_loop_default_detection-0.4.0}/tests/test_counterfactual.py +0 -0
  47. {closed_loop_default_detection-0.3.0 → closed_loop_default_detection-0.4.0}/tests/test_diagnostics.py +0 -0
  48. {closed_loop_default_detection-0.3.0 → closed_loop_default_detection-0.4.0}/tests/test_emp.py +0 -0
  49. {closed_loop_default_detection-0.3.0 → closed_loop_default_detection-0.4.0}/tests/test_eval_default.py +0 -0
  50. {closed_loop_default_detection-0.3.0 → closed_loop_default_detection-0.4.0}/tests/test_exploration.py +0 -0
  51. {closed_loop_default_detection-0.3.0 → closed_loop_default_detection-0.4.0}/tests/test_feedback.py +0 -0
  52. {closed_loop_default_detection-0.3.0 → closed_loop_default_detection-0.4.0}/tests/test_fidelity_report.py +0 -0
  53. {closed_loop_default_detection-0.3.0 → closed_loop_default_detection-0.4.0}/tests/test_loop.py +0 -0
  54. {closed_loop_default_detection-0.3.0 → closed_loop_default_detection-0.4.0}/tests/test_loop_scm.py +0 -0
  55. {closed_loop_default_detection-0.3.0 → closed_loop_default_detection-0.4.0}/tests/test_model_pd.py +0 -0
  56. {closed_loop_default_detection-0.3.0 → closed_loop_default_detection-0.4.0}/tests/test_reject_inference.py +0 -0
  57. {closed_loop_default_detection-0.3.0 → closed_loop_default_detection-0.4.0}/tests/test_scm_gating.py +0 -0
  58. {closed_loop_default_detection-0.3.0 → closed_loop_default_detection-0.4.0}/tests/test_sklearn_compat.py +0 -0
  59. {closed_loop_default_detection-0.3.0 → closed_loop_default_detection-0.4.0}/tests/test_sweep_stats.py +0 -0
  60. {closed_loop_default_detection-0.3.0 → closed_loop_default_detection-0.4.0}/tests/test_synthetic.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: closed-loop-default-detection
3
- Version: 0.3.0
3
+ Version: 0.4.0
4
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: Hossain Pazooki
6
6
  License-Expression: MIT
@@ -26,6 +26,7 @@ Requires-Dist: matplotlib>=3.8
26
26
  Provides-Extra: dev
27
27
  Requires-Dist: pytest>=8.0; extra == "dev"
28
28
  Requires-Dist: pytest-cov>=5; extra == "dev"
29
+ Requires-Dist: build>=1; extra == "dev"
29
30
  Provides-Extra: docs
30
31
  Requires-Dist: sphinx>=7; extra == "docs"
31
32
  Requires-Dist: furo; extra == "docs"
@@ -48,134 +49,14 @@ hides labels the way real approval policies do, and grades every correction agai
48
49
  - **Deterministic** — byte-identical per seed, scikit-learn-only, no services or GPUs.
49
50
  - **Pluggable** — correction levers (IPW, retrain, exploration, reject inference) are classes;
50
51
  add yours by subclassing `Corrector`.
51
- - **Honest by construction** — every number below recomputes from committed CSVs; limits are
52
- reported, not smoothed over.
53
-
54
- ## The result it produces
55
-
56
- The loop escalates selection severity until correction fails and reports the **operating
57
- frontier** — the last severity at which declined-cohort calibration still holds (target
58
- ECE ≤ 0.10). From the committed runs (`artifacts/loop_frontier*.csv`, seed 42):
59
-
60
- | Selection severity | 0.0 | 0.2 | 0.4 | 0.6 |
61
- |---|---|---|---|---|
62
- | Naive declined ECE (flat world) | 0.021 | 0.045 | 0.108 | 0.161 |
63
- | **IPW-corrected** (flat world) | 0.020 | 0.038 | **0.086 ✓** | **0.154 ✗** |
64
- | **IPW-corrected** (SCM world) | 0.036 | 0.038 | **0.097 ✓** | **0.244 ✗** |
65
-
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.
96
-
97
- Reproduce the headline from committed evidence: `python scripts/paired_significance.py`.
98
- The full independent assessment (methodology, all numbers, what didn't hold) is the
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.
150
-
151
- ## Pricing the feedback loop (v3)
152
-
153
- `v0.3.0` prices the **dynamic** regime, where the model's own approvals create its next
154
- training set: three arms (treatment = retrain each generation; **frozen** = generation-0
155
- model deployed forever; **prior** = prior-policy funding), 25 spaced seeds × 3 severities ×
156
- 2 exploration rates × 12 generations = **450 runs**
157
- (`artifacts/feedback_profit_sweep.csv`), with three pre-registered hypotheses tested as
158
- within-seed paired contrasts on realized funded-book P&L at severity 0.4, Holm-corrected.
159
- Every number below recomputes via `python scripts/feedback_sweep_stats.py`. All of it
160
- consumes **planted, risk-unlinked default timing** — a verified *experiment*, not a
161
- verified *result*.
162
-
163
- | Hypothesis (severity 0.4, gens 1–11) | Median paired deficit | Sign | Verdict |
164
- |---|---|---|---|
165
- | H1 feedback accumulation costs money (treatment − frozen, ε=0) | **−0.0028** | 24/25 | **confirmed** (p_holm 2.3e-06, clears the 0.0018 noise floor) |
166
- | H2 the policy switch costs money (frozen − prior, ε=0) | +0.0119 | 0/25 | **not confirmed — measured opposite in direction** |
167
- | H3 exploration buys profit back (treatment ε=.05 − ε=0) | −0.0024 | 0/25 | **not confirmed** |
168
-
169
- The decomposition v3 exists for came back one-for-three: letting the loop close on its own
170
- labels measurably costs the book (H1), but at these severities the *switch* to model-based
171
- funding is profitable relative to the prior policy (H2's predicted sign was wrong — reported
172
- as measured, not softened), and 5% exploration does not pay for itself in-window (H3; the
173
- policy-book-only secondary H3a is negative too). The **H4 integrity control** — frozen-arm
174
- paired difference must equal the explored slice's own P&L *exactly* — holds at ≤1e-10 across
175
- all 900 checked rows, and the answer to the v2-banked question is re-verified at 25 seeds:
176
- the declined-ECE alarm fires at generation 1–2 in every ε=0 run (severity 0.4: 25/25 at
177
- generation 1). Full mechanics in [`docs/how-it-works.md`](docs/how-it-works.md); gates in
178
- [`docs/validation.md`](docs/validation.md).
52
+ - **Honest by construction** — every results figure registered in the doc-number gate
53
+ recomputes from a committed CSV in CI, and every artifact is pinned by content hash; the
54
+ results and their limits are in [docs/results.md](docs/results.md).
55
+
56
+ > **What this is — and is not.** CLDD is a *validation harness* graded against planted
57
+ > synthetic ground truth; it is **not a credit-modeling toolkit**. The correction levers it
58
+ > ships (IPW, retrain, exploration, reject inference) exist to be *measured against a known
59
+ > answer* — nothing in this repo is validated for making decisions on real borrower data.
179
60
 
180
61
  ## Install
181
62
 
@@ -277,17 +158,18 @@ python scripts/paired_significance.py # recompute the headline stat from
277
158
 
278
159
  ## Validation
279
160
 
280
- `pytest` — 149 tests, all synthetic, no real data needed. CI runs a pinned-repro job (exact
281
- pins), a cross-version/OS compat matrix, and a strict docs build. Six float-sensitive tests
282
- reproduce only under the pins in `requirements-dev.txt`; the optional marginal-fidelity gate
283
- compares the SCM against a **private** real dataset via `CLDD_DATA_DIR` and is the only thing
284
- that needs it. Details, reproducibility, and troubleshooting:
285
- [docs/validation.md](docs/validation.md).
161
+ `pytest`, all synthetic, no real data needed. CI runs a pinned-repro job (exact pins), a
162
+ cross-version/OS compat matrix, and a strict docs build; a doc-number gate recomputes every
163
+ registered figure in the docs from a committed artifact. A few float-sensitive tests reproduce
164
+ only under the pins in `requirements-dev.txt`; the optional marginal-fidelity gate compares
165
+ the SCM against a **private** real dataset via `CLDD_DATA_DIR` and is the only thing that
166
+ needs it. Details, reproducibility, and troubleshooting: [docs/validation.md](docs/validation.md).
286
167
 
287
168
  ## Documentation
288
169
 
289
170
  | Where | What |
290
171
  |---|---|
172
+ | [docs/results.md](docs/results.md) | **every measured result** — the frontier and its distribution, the v4 intervention, the two pre-registered experiments, the two pricing layers |
291
173
  | [docs/quickstart.md](docs/quickstart.md) | run the loop, the counterfactual eval, the fidelity report |
292
174
  | [docs/how-it-works.md](docs/how-it-works.md) | loop mechanics, diagnostics, feedback simulation, repo map |
293
175
  | [docs/configuration.md](docs/configuration.md) | every knob (`config.py`) and the one env var |
@@ -299,12 +181,8 @@ Build locally: `pip install -e ".[docs]" && sphinx-build -b html -W docs docs/_b
299
181
 
300
182
  ## Status
301
183
 
302
- `0.2.0` **alpha** on [PyPI](https://pypi.org/project/closed-loop-default-detection/),
303
- changelog in [CHANGELOG.md](CHANGELOG.md).
304
-
305
- Shipped in 0.1.0: the loop, both synthetic worlds, all correction levers, the fidelity gate,
306
- the sklearn estimator, CI on three gates. Added in 0.2.0: `cldd.emp` (both EMP variants),
307
- priced exploration, the EMP-optimal cutoff, and the 25-seed frontier sweep.
184
+ **Alpha**, on [PyPI](https://pypi.org/project/closed-loop-default-detection/); version
185
+ history in [CHANGELOG.md](CHANGELOG.md).
308
186
 
309
187
  CLDD began as a validation harness for the Intuit TechWeek SMB Underwriting Challenge; it is
310
188
  not a submission and does not alter challenge files.
@@ -319,7 +197,7 @@ Metadata in [`CITATION.cff`](CITATION.cff) (GitHub's "Cite this repository" read
319
197
  title = {{closed-loop-default-detection}: measuring selective-labels default
320
198
  detection and the PD model's operating frontier},
321
199
  year = {2026},
322
- version = {0.2.0},
200
+ version = {0.4.0},
323
201
  license = {MIT},
324
202
  url = {https://github.com/hossainpazooki/closed-loop-default-detection}
325
203
  }
@@ -0,0 +1,169 @@
1
+ # CLDD — closed-loop default detection
2
+
3
+ [![CI](https://github.com/hossainpazooki/closed-loop-default-detection/actions/workflows/ci.yml/badge.svg)](https://github.com/hossainpazooki/closed-loop-default-detection/actions/workflows/ci.yml)
4
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
5
+ [![Docs: Sphinx](https://img.shields.io/badge/docs-sphinx-blue.svg)](docs/)
6
+ [![Python ≥3.10](https://img.shields.io/badge/python-%E2%89%A53.10-blue.svg)](pyproject.toml)
7
+
8
+ **Stress-test a probability-of-default (PD) model under *selective labels* — and get the
9
+ severity at which it breaks.** Real lending data only labels the loans a prior underwriter
10
+ approved, so you cannot measure calibration on the applicants you *declined* — exactly where a
11
+ new model must still be right. CLDD builds synthetic lending worlds with planted ground truth,
12
+ hides labels the way real approval policies do, and grades every correction against that truth.
13
+
14
+ - **Deterministic** — byte-identical per seed, scikit-learn-only, no services or GPUs.
15
+ - **Pluggable** — correction levers (IPW, retrain, exploration, reject inference) are classes;
16
+ add yours by subclassing `Corrector`.
17
+ - **Honest by construction** — every results figure registered in the doc-number gate
18
+ recomputes from a committed CSV in CI, and every artifact is pinned by content hash; the
19
+ results and their limits are in [docs/results.md](docs/results.md).
20
+
21
+ > **What this is — and is not.** CLDD is a *validation harness* graded against planted
22
+ > synthetic ground truth; it is **not a credit-modeling toolkit**. The correction levers it
23
+ > ships (IPW, retrain, exploration, reject inference) exist to be *measured against a known
24
+ > answer* — nothing in this repo is validated for making decisions on real borrower data.
25
+
26
+ ## Install
27
+
28
+ ```bash
29
+ pip install closed-loop-default-detection
30
+ ```
31
+
32
+ The import name is **`cldd`**. For development (tests, docs, the committed evidence),
33
+ install from source:
34
+
35
+ ```bash
36
+ git clone https://github.com/hossainpazooki/closed-loop-default-detection.git
37
+ cd closed-loop-default-detection
38
+ pip install -e ".[dev]"
39
+ ```
40
+
41
+ Python ≥ 3.10; dependencies are ranges (`numpy>=2.0`, `pandas>=2.2`, `scikit-learn>=1.6`,
42
+ `scipy>=1.11`, `matplotlib>=3.8`) so `cldd` sits alongside your stack. Exact pins for
43
+ float-exact reproduction: [`requirements-dev.txt`](requirements-dev.txt)
44
+ ([details](docs/validation.md)).
45
+
46
+ ## 60-second tour
47
+
48
+ ```python
49
+ from cldd import SelectiveLabelsLoop
50
+
51
+ result = SelectiveLabelsLoop(improve_mode="both").run() # "reweight" | "retrain" | "both"
52
+ print("Operating frontier:", result.frontier_severity)
53
+ for r in result.rounds:
54
+ print(r.selection_severity, r.naive.declined_ece, r.passed)
55
+ ```
56
+
57
+ ```mermaid
58
+ flowchart TD
59
+ A["<b>1. Generate</b><br/>synthetic cohort at a given selection severity<br/>plant true default, then hide it via the approval policy"]
60
+ B["<b>2. Measure</b><br/>train the PD model on approved rows only,<br/>score it against planted truth on the declined subpopulation"]
61
+ C["<b>3. Improve</b><br/>apply a correction lever:<br/>IPW reweight &middot; disjoint retrain &middot; exploration"]
62
+ D{"Corrected declined-cohort<br/>ECE &le; target?"}
63
+ E["<b>Operating frontier</b><br/>report the highest severity<br/>that still passes"]
64
+
65
+ A --> B --> C --> D
66
+ D -->|"yes &mdash; raise the severity"| A
67
+ D -->|"no &mdash; stop"| E
68
+ ```
69
+
70
+ A runnable end-to-end demo (classic + custom-lever paths) is
71
+ [`examples/quickstart.py`](examples/quickstart.py). Full mechanics, diagnostics, and the
72
+ feedback simulation: [docs/how-it-works.md](docs/how-it-works.md).
73
+
74
+ > **Scope.** CLDD is a synthetic **validation harness**, not a production pipeline: retraining
75
+ > and feedback are seeded simulations inside the harness; it never acts on live data or real
76
+ > lending decisions.
77
+
78
+ ## What's in the box
79
+
80
+ Everything is importable from top-level `cldd` (full reference: the [Sphinx docs](docs/)):
81
+
82
+ | Import | What it is |
83
+ |---|---|
84
+ | `SelectiveLabelsLoop` | the closed loop; `.run()` → `LoopResult` (frontier + per-round metrics) |
85
+ | `Corrector` + `NaiveCorrector`, `IPWReweightCorrector`, `DisjointRetrainCorrector`, `ExplorationCorrector` | the lever ABC and the four built-ins |
86
+ | `ReclassificationCorrector`, `AugmentationCorrector`, `FuzzyAugmentationCorrector`, `ParcellingCorrector` | four classic reject-inference methods, graded against planted truth ([honest results](docs/reject_inference.md)) |
87
+ | `SyntheticBorrowerGenerator`, `StructuralBorrowerGenerator` | the flat and fitted-SCM synthetic worlds |
88
+ | `run_counterfactual_eval`, `GComputationEstimator` | counterfactual validator (g-computation vs naive conditioning) |
89
+ | `FeedbackLoop` | model-in-the-loop selective-labels simulation |
90
+ | `empc_literature`, `emp_harness` | the two EMP variants — price the frontier in profit (reporting only, never loop control) |
91
+ | `positivity_diagnostics` | observable regime/drift alarm — needs **no** declined-row labels |
92
+ | `CalibratedPDClassifier` | the calibrated PD detector as a scikit-learn estimator |
93
+ | `cldd.fidelity.run_fidelity_gate` | SCM-vs-real **marginal**-fidelity gate (univariate marginals only) |
94
+
95
+ **Add a lever** by subclassing `Corrector` (`name`, `control_priority`, `apply`) and passing
96
+ `correctors=[NaiveCorrector(), MyCorrector()]` — the legacy `improve_mode` API is unchanged
97
+ and byte-identical. Contract details: [CONTRIBUTING.md](CONTRIBUTING.md).
98
+
99
+ **Use the detector from sklearn tooling** — `CalibratedPDClassifier` is a thin, tested wrapper
100
+ (binary-only; NaN features OK; the full `check_estimator` battery passes with zero failed
101
+ checks on scikit-learn 1.7.2–1.9.0; probabilities byte-identical to the research API):
102
+
103
+ ```python
104
+ from sklearn.model_selection import cross_val_score
105
+ from cldd import CalibratedPDClassifier
106
+
107
+ scores = cross_val_score(CalibratedPDClassifier(random_state=42), X, y, scoring="neg_brier_score")
108
+ ```
109
+
110
+ ## Command-line drivers
111
+
112
+ Each driver runs without install (adds `src/` to the path) and writes to `artifacts/`:
113
+
114
+ ```bash
115
+ python scripts/run_loop.py # the closed loop → frontier table + plot (--generator scm for the SCM world)
116
+ python scripts/run_frontier_sweep.py --quick # frontier distribution across the 25-seed set (drop --quick for all)
117
+ python scripts/run_seed_sweep.py --quick # counterfactual certification (drop --quick for all seeds)
118
+ python scripts/run_reject_inference.py # reject-inference levers vs the frontier
119
+ python scripts/run_exploration_sweep.py # frontier vs exploration budget
120
+ python scripts/run_feedback.py # model-in-the-loop feedback simulation
121
+ python scripts/paired_significance.py # recompute the headline stat from committed CSVs
122
+ ```
123
+
124
+ ## Validation
125
+
126
+ `pytest`, all synthetic, no real data needed. CI runs a pinned-repro job (exact pins), a
127
+ cross-version/OS compat matrix, and a strict docs build; a doc-number gate recomputes every
128
+ registered figure in the docs from a committed artifact. A few float-sensitive tests reproduce
129
+ only under the pins in `requirements-dev.txt`; the optional marginal-fidelity gate compares
130
+ the SCM against a **private** real dataset via `CLDD_DATA_DIR` and is the only thing that
131
+ needs it. Details, reproducibility, and troubleshooting: [docs/validation.md](docs/validation.md).
132
+
133
+ ## Documentation
134
+
135
+ | Where | What |
136
+ |---|---|
137
+ | [docs/results.md](docs/results.md) | **every measured result** — the frontier and its distribution, the v4 intervention, the two pre-registered experiments, the two pricing layers |
138
+ | [docs/quickstart.md](docs/quickstart.md) | run the loop, the counterfactual eval, the fidelity report |
139
+ | [docs/how-it-works.md](docs/how-it-works.md) | loop mechanics, diagnostics, feedback simulation, repo map |
140
+ | [docs/configuration.md](docs/configuration.md) | every knob (`config.py`) and the one env var |
141
+ | [docs/validation.md](docs/validation.md) | tests, gates, reproducibility, troubleshooting |
142
+ | [docs/reject_inference.md](docs/reject_inference.md) | the four RI methods and their honest (modest) results |
143
+ | [`docs/assessment.md`](docs/assessment.md) | **the accompanying article** — independent results & methodology assessment (dated snapshot) |
144
+
145
+ Build locally: `pip install -e ".[docs]" && sphinx-build -b html -W docs docs/_build/html`.
146
+
147
+ ## Status
148
+
149
+ **Alpha**, on [PyPI](https://pypi.org/project/closed-loop-default-detection/); version
150
+ history in [CHANGELOG.md](CHANGELOG.md).
151
+
152
+ CLDD began as a validation harness for the Intuit TechWeek SMB Underwriting Challenge; it is
153
+ not a submission and does not alter challenge files.
154
+
155
+ ## Citation
156
+
157
+ Metadata in [`CITATION.cff`](CITATION.cff) (GitHub's "Cite this repository" reads it):
158
+
159
+ ```bibtex
160
+ @software{pazooki_cldd_2026,
161
+ author = {Pazooki, Hossain},
162
+ title = {{closed-loop-default-detection}: measuring selective-labels default
163
+ detection and the PD model's operating frontier},
164
+ year = {2026},
165
+ version = {0.4.0},
166
+ license = {MIT},
167
+ url = {https://github.com/hossainpazooki/closed-loop-default-detection}
168
+ }
169
+ ```
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "closed-loop-default-detection"
7
- version = "0.3.0"
7
+ version = "0.4.0"
8
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"
@@ -45,7 +45,7 @@ dependencies = [
45
45
  ]
46
46
 
47
47
  [project.optional-dependencies]
48
- dev = ["pytest>=8.0", "pytest-cov>=5"]
48
+ dev = ["pytest>=8.0", "pytest-cov>=5", "build>=1"]
49
49
  docs = ["sphinx>=7", "furo", "myst-parser>=2"]
50
50
 
51
51
  [project.urls]
@@ -70,7 +70,7 @@ markers = [
70
70
  # Coverage is opt-in (run `pytest --cov=cldd`); it is not wired into addopts so a
71
71
  # plain `pytest` stays lightweight. The private-data fidelity path (cldd/fidelity.py)
72
72
  # is only exercised when the real dataset is present, so its data-loading branch is
73
- # expected to be uncovered on public CI by design — see README.
73
+ # expected to be uncovered on public CI by design — see docs/validation.md.
74
74
  [tool.coverage.run]
75
75
  source = ["cldd"]
76
76
  branch = true
@@ -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.2.0"
14
+ __version__ = "0.4.0"
15
15
 
16
16
  from .correctors import (
17
17
  CorrectionOutcome,
@@ -57,6 +57,7 @@ from .scm import (
57
57
  dag_children,
58
58
  dag_parents,
59
59
  )
60
+ from .selection import SegmentedSelection, knockout_rates
60
61
  from .synthetic import SyntheticBorrowerGenerator
61
62
 
62
63
  __all__ = [
@@ -105,4 +106,6 @@ __all__ = [
105
106
  "GComputationEstimator",
106
107
  "run_counterfactual_eval",
107
108
  "generate_queries",
109
+ "SegmentedSelection",
110
+ "knockout_rates",
108
111
  ]
@@ -632,6 +632,7 @@ def run_counterfactual_eval(
632
632
  n_query_applicants: int = 300,
633
633
  seed: int = config.RANDOM_SEED,
634
634
  generator: StructuralBorrowerGenerator | None = None,
635
+ unobserved_strength: float | None = None,
635
636
  ) -> CounterfactualResult:
636
637
  """Build a cohort, generate Deliverable-C-style queries, grade the estimators.
637
638
 
@@ -644,13 +645,27 @@ def run_counterfactual_eval(
644
645
  Both deployable estimators are fit on the APPROVED rows only (selective-labels
645
646
  regime), so their conditionals are distorted by selection — exactly the
646
647
  confound g-computation's backdoor adjustment is meant to repair.
648
+
649
+ ``unobserved_strength`` (v4 surface knob) is forwarded to the internal
650
+ ``StructuralBorrowerGenerator`` construction only when ``generator`` is None;
651
+ the effective value is echoed in ``meta``. ``None`` (the default) is
652
+ byte-identical to the pre-knob path.
647
653
  """
654
+ if generator is not None and unobserved_strength is not None:
655
+ raise ValueError(
656
+ "unobserved_strength is forwarded to the internal generator "
657
+ "construction only; pass it OR a pre-built generator, not both "
658
+ "(silently ignoring it would let a driver drift from the eval)"
659
+ )
648
660
  if generator is None:
649
- generator = StructuralBorrowerGenerator(
661
+ gen_kwargs: dict = dict(
650
662
  n_applicants=n_applicants,
651
663
  selection_severity=selection_severity,
652
664
  seed=seed,
653
665
  )
666
+ if unobserved_strength is not None:
667
+ gen_kwargs["unobserved_strength"] = unobserved_strength
668
+ generator = StructuralBorrowerGenerator(**gen_kwargs)
654
669
  cohort = generator.generate_cohort()
655
670
  state = cohort["scm_state"]
656
671
  features = cohort["features"]
@@ -763,5 +778,6 @@ def run_counterfactual_eval(
763
778
  "n_applicants": n_applicants,
764
779
  "seed": seed,
765
780
  "approval_rate": float(approved.mean()),
781
+ "unobserved_strength": float(generator.unobserved_strength),
766
782
  },
767
783
  )
@@ -1,7 +1,7 @@
1
1
  """Expected-maximum-profit (EMP) reporting layer — the v2 measurement axis.
2
2
 
3
3
  Prices the frontier the loop already measures; **never** a loop-control input
4
- (spec: ``docs/superpowers/specs/2026-07-13-cldd-v2-emp-design.md``). Two
4
+ (spec: ``2026-07-13-cldd-v2-emp-design.md``). Two
5
5
  variants, side by side:
6
6
 
7
7
  * :func:`empc_literature` — the Verbraken/Bravo/Weber/Baesens EMPC closed form
@@ -18,7 +18,7 @@ of mean per-loan exposure** (exposure = ``requested_amount``), so the two
18
18
  columns are directly comparable. Both consume ``scores`` as a ranking only and
19
19
  are invariant to strictly monotone transforms.
20
20
 
21
- Reading caveats (documented in README + CSV column notes):
21
+ Reading caveats (documented in docs/results.md + CSV column notes):
22
22
 
23
23
  * Planted default timing is **marginally spec-shaped but conditionally
24
24
  cosmetic** — independent of features/risk given default status — so harness
@@ -23,7 +23,7 @@ correction (IPW, retrain) is undefined on the declined side.
23
23
  labeled-propensity weights — the identification-buying lever from the static
24
24
  loop, now acting as a *stabilizer* of the feedback dynamics.
25
25
 
26
- v3 (docs/superpowers/specs/2026-07-14-cldd-v3-design.md) adds two additive flags,
26
+ v3 (spec 2026-07-14-cldd-v3-design.md) adds two additive flags,
27
27
  byte-exact at their defaults, that isolate the trajectory's causes into three
28
28
  arms: ``retrain=False`` freezes the generation-0 model forever (isolates
29
29
  feedback accumulation — the "frozen" arm), and ``policy_mode="prior"`` funds
@@ -53,19 +53,14 @@ from .scm import StructuralBorrowerGenerator
53
53
  # Configuration
54
54
  # --------------------------------------------------------------------------- #
55
55
 
56
- #: Historical author-machine dataset location, kept only as a last-resort fallback
57
- #: so the maintainer's local ``check_fidelity`` run needs no configuration.
58
- _FALLBACK_DATA_DIR = Path(
59
- "C:/Users/hossa/dev/intuit-techweek-hackathon/"
60
- "intuit-techweek-nyc-hackathon-2026/dataset"
61
- )
62
-
63
- #: Default location of the real Intuit dataset. The dataset is **private and not
56
+ #: Default location of the real Intuit dataset, read from the ``CLDD_DATA_DIR``
57
+ #: environment variable; ``None`` when it is unset. The dataset is **private and not
64
58
  #: shipped with the package**, so the fidelity gate is runnable only where that
65
- #: data is available. Point the gate at your copy with the ``CLDD_DATA_DIR``
66
- #: environment variable (the portable knob) or by passing ``data_dir=`` explicitly;
67
- #: absent both, this falls back to the maintainer's historical path.
68
- DEFAULT_DATA_DIR = Path(os.environ.get("CLDD_DATA_DIR", _FALLBACK_DATA_DIR))
59
+ #: data is available. ``CLDD_DATA_DIR`` is the only data-location knob; ``data_dir=``
60
+ #: on the loaders overrides it per call.
61
+ DEFAULT_DATA_DIR: Path | None = (
62
+ Path(os.environ["CLDD_DATA_DIR"]) if "CLDD_DATA_DIR" in os.environ else None
63
+ )
69
64
 
70
65
  #: Continuous features compared on p1/p50/p99 (modeled in the SCM marginals).
71
66
  CONTINUOUS_FEATURES = (
@@ -246,15 +241,29 @@ class FidelityReport:
246
241
  # --------------------------------------------------------------------------- #
247
242
 
248
243
 
249
- def load_real(data_dir: str | Path = DEFAULT_DATA_DIR, split: str = "train") -> pd.DataFrame:
244
+ def _resolve_data_dir(data_dir: str | Path | None) -> Path:
245
+ if data_dir is None:
246
+ data_dir = DEFAULT_DATA_DIR
247
+ if data_dir is None:
248
+ raise FileNotFoundError(
249
+ "real Intuit dataset location is not set. The dataset is private and is "
250
+ "not shipped with cldd; set the CLDD_DATA_DIR environment variable (or "
251
+ "pass data_dir=) to the directory containing train.csv."
252
+ )
253
+ return Path(data_dir)
254
+
255
+
256
+ def load_real(data_dir: str | Path | None = None, split: str = "train") -> pd.DataFrame:
250
257
  """Load a split of the real Intuit dataset as a DataFrame.
251
258
 
252
259
  Parameters
253
260
  ----------
254
- data_dir : path to the dataset directory (defaults to :data:`DEFAULT_DATA_DIR`).
261
+ data_dir : path to the dataset directory; ``None`` means :data:`DEFAULT_DATA_DIR`
262
+ (the ``CLDD_DATA_DIR`` environment variable), and if that is unset too a
263
+ ``FileNotFoundError`` names the variable.
255
264
  split : one of ``"train"``, ``"validation"``, ``"test"`` (file ``<split>.csv``).
256
265
  """
257
- path = Path(data_dir) / f"{split}.csv"
266
+ path = _resolve_data_dir(data_dir) / f"{split}.csv"
258
267
  if not path.exists():
259
268
  raise FileNotFoundError(
260
269
  f"real Intuit dataset not found: {path}. The dataset is private and is "
@@ -411,7 +420,7 @@ def compute_fidelity(
411
420
 
412
421
 
413
422
  def run_fidelity_gate(
414
- data_dir: str | Path = DEFAULT_DATA_DIR,
423
+ data_dir: str | Path | None = None,
415
424
  split: str = "train",
416
425
  n_applicants: int = 12000,
417
426
  seed: int = 42,
@@ -426,5 +435,5 @@ def run_fidelity_gate(
426
435
  n_applicants=n_applicants, seed=seed, generator=generator, **generator_kwargs
427
436
  )
428
437
  report = compute_fidelity(real, cohort, strict_categoricals=strict_categoricals)
429
- report.data_dir = str(data_dir)
438
+ report.data_dir = str(data_dir if data_dir is not None else DEFAULT_DATA_DIR)
430
439
  return report
@@ -92,13 +92,18 @@ def make_generator(
92
92
  seed: int,
93
93
  n_applicants: int,
94
94
  approval_rate: float,
95
+ **extra,
95
96
  ):
96
97
  """Fresh single-shot cohort generator (shared by this loop and ``feedback``).
97
98
 
98
99
  A fresh instance per cohort is required for determinism — both generator
99
100
  classes consume their own ``Generator(PCG64(seed))`` on ``generate_cohort``,
100
101
  so reusing an instance would silently shift streams. The SCM path always
101
- sets ``independent_selection_noise=True`` (see ``GENERATORS`` note).
102
+ sets ``independent_selection_noise=True`` (see ``GENERATORS`` note). Any
103
+ ``**extra`` kwargs are forwarded verbatim to the generator constructor (v4:
104
+ the surface driver passes ``unobserved_strength``); the scm path's
105
+ ``independent_selection_noise=True`` cannot be overridden this way — a
106
+ duplicate keyword raises ``TypeError``.
102
107
  """
103
108
  if generator not in GENERATORS:
104
109
  raise ValueError(f"generator must be one of {GENERATORS}; got {generator!r}")
@@ -118,12 +123,14 @@ def make_generator(
118
123
  approval_rate=approval_rate,
119
124
  seed=seed,
120
125
  independent_selection_noise=True,
126
+ **extra,
121
127
  )
122
128
  return SyntheticBorrowerGenerator(
123
129
  n_applicants=n_applicants,
124
130
  selection_severity=severity,
125
131
  approval_rate=approval_rate,
126
132
  seed=seed,
133
+ **extra,
127
134
  )
128
135
 
129
136
 
@@ -203,6 +210,7 @@ class SelectiveLabelsLoop:
203
210
  generator: str = "flat",
204
211
  exploration_rate: float = 0.0,
205
212
  correctors: list[Corrector] | None = None,
213
+ generator_kwargs: dict | None = None,
206
214
  ) -> None:
207
215
  if improve_mode not in IMPROVE_MODES:
208
216
  raise ValueError(f"improve_mode must be one of {IMPROVE_MODES}; got {improve_mode!r}")
@@ -211,6 +219,9 @@ class SelectiveLabelsLoop:
211
219
  if not 0.0 <= exploration_rate < 1.0:
212
220
  raise ValueError(f"exploration_rate must be in [0, 1); got {exploration_rate!r}")
213
221
  self.generator = generator
222
+ # Extra constructor kwargs forwarded to make_generator (v4 surface knob).
223
+ # None (the default) is byte-identical to the pre-knob path.
224
+ self.generator_kwargs = generator_kwargs
214
225
  self.exploration_rate = exploration_rate
215
226
  self.target_declined_ece = target_declined_ece
216
227
  self.start_severity = start_severity
@@ -265,6 +276,7 @@ class SelectiveLabelsLoop:
265
276
  seed=seed,
266
277
  n_applicants=self.n_applicants,
267
278
  approval_rate=self.approval_rate,
279
+ **(self.generator_kwargs or {}),
268
280
  )
269
281
 
270
282
  def _generate(self, severity: float, iteration: int) -> dict: