closed-loop-default-detection 0.2.0__tar.gz → 0.3.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.2.0/src/closed_loop_default_detection.egg-info → closed_loop_default_detection-0.3.0}/PKG-INFO +31 -2
- {closed_loop_default_detection-0.2.0 → closed_loop_default_detection-0.3.0}/README.md +29 -0
- {closed_loop_default_detection-0.2.0 → closed_loop_default_detection-0.3.0}/pyproject.toml +2 -2
- {closed_loop_default_detection-0.2.0 → closed_loop_default_detection-0.3.0}/src/cldd/emp.py +47 -0
- {closed_loop_default_detection-0.2.0 → closed_loop_default_detection-0.3.0}/src/cldd/feedback.py +69 -12
- {closed_loop_default_detection-0.2.0 → closed_loop_default_detection-0.3.0/src/closed_loop_default_detection.egg-info}/PKG-INFO +31 -2
- {closed_loop_default_detection-0.2.0 → closed_loop_default_detection-0.3.0}/src/closed_loop_default_detection.egg-info/SOURCES.txt +3 -0
- closed_loop_default_detection-0.3.0/tests/test_book_profit.py +329 -0
- closed_loop_default_detection-0.3.0/tests/test_feedback_flags.py +307 -0
- closed_loop_default_detection-0.3.0/tests/test_sweep_stats.py +501 -0
- {closed_loop_default_detection-0.2.0 → closed_loop_default_detection-0.3.0}/LICENSE +0 -0
- {closed_loop_default_detection-0.2.0 → closed_loop_default_detection-0.3.0}/setup.cfg +0 -0
- {closed_loop_default_detection-0.2.0 → closed_loop_default_detection-0.3.0}/src/cldd/__init__.py +0 -0
- {closed_loop_default_detection-0.2.0 → closed_loop_default_detection-0.3.0}/src/cldd/config.py +0 -0
- {closed_loop_default_detection-0.2.0 → closed_loop_default_detection-0.3.0}/src/cldd/correctors.py +0 -0
- {closed_loop_default_detection-0.2.0 → closed_loop_default_detection-0.3.0}/src/cldd/counterfactual.py +0 -0
- {closed_loop_default_detection-0.2.0 → closed_loop_default_detection-0.3.0}/src/cldd/diagnostics.py +0 -0
- {closed_loop_default_detection-0.2.0 → closed_loop_default_detection-0.3.0}/src/cldd/eval_default.py +0 -0
- {closed_loop_default_detection-0.2.0 → closed_loop_default_detection-0.3.0}/src/cldd/fidelity.py +0 -0
- {closed_loop_default_detection-0.2.0 → closed_loop_default_detection-0.3.0}/src/cldd/loop.py +0 -0
- {closed_loop_default_detection-0.2.0 → closed_loop_default_detection-0.3.0}/src/cldd/model_pd.py +0 -0
- {closed_loop_default_detection-0.2.0 → closed_loop_default_detection-0.3.0}/src/cldd/py.typed +0 -0
- {closed_loop_default_detection-0.2.0 → closed_loop_default_detection-0.3.0}/src/cldd/reject_inference.py +0 -0
- {closed_loop_default_detection-0.2.0 → closed_loop_default_detection-0.3.0}/src/cldd/scm.py +0 -0
- {closed_loop_default_detection-0.2.0 → closed_loop_default_detection-0.3.0}/src/cldd/synthetic.py +0 -0
- {closed_loop_default_detection-0.2.0 → closed_loop_default_detection-0.3.0}/src/closed_loop_default_detection.egg-info/dependency_links.txt +0 -0
- {closed_loop_default_detection-0.2.0 → closed_loop_default_detection-0.3.0}/src/closed_loop_default_detection.egg-info/requires.txt +0 -0
- {closed_loop_default_detection-0.2.0 → closed_loop_default_detection-0.3.0}/src/closed_loop_default_detection.egg-info/top_level.txt +0 -0
- {closed_loop_default_detection-0.2.0 → closed_loop_default_detection-0.3.0}/tests/test_correctors.py +0 -0
- {closed_loop_default_detection-0.2.0 → closed_loop_default_detection-0.3.0}/tests/test_counterfactual.py +0 -0
- {closed_loop_default_detection-0.2.0 → closed_loop_default_detection-0.3.0}/tests/test_diagnostics.py +0 -0
- {closed_loop_default_detection-0.2.0 → closed_loop_default_detection-0.3.0}/tests/test_emp.py +0 -0
- {closed_loop_default_detection-0.2.0 → closed_loop_default_detection-0.3.0}/tests/test_eval_default.py +0 -0
- {closed_loop_default_detection-0.2.0 → closed_loop_default_detection-0.3.0}/tests/test_exploration.py +0 -0
- {closed_loop_default_detection-0.2.0 → closed_loop_default_detection-0.3.0}/tests/test_feedback.py +0 -0
- {closed_loop_default_detection-0.2.0 → closed_loop_default_detection-0.3.0}/tests/test_fidelity.py +0 -0
- {closed_loop_default_detection-0.2.0 → closed_loop_default_detection-0.3.0}/tests/test_fidelity_report.py +0 -0
- {closed_loop_default_detection-0.2.0 → closed_loop_default_detection-0.3.0}/tests/test_loop.py +0 -0
- {closed_loop_default_detection-0.2.0 → closed_loop_default_detection-0.3.0}/tests/test_loop_scm.py +0 -0
- {closed_loop_default_detection-0.2.0 → closed_loop_default_detection-0.3.0}/tests/test_model_pd.py +0 -0
- {closed_loop_default_detection-0.2.0 → closed_loop_default_detection-0.3.0}/tests/test_reject_inference.py +0 -0
- {closed_loop_default_detection-0.2.0 → closed_loop_default_detection-0.3.0}/tests/test_scm_gating.py +0 -0
- {closed_loop_default_detection-0.2.0 → closed_loop_default_detection-0.3.0}/tests/test_sklearn_compat.py +0 -0
- {closed_loop_default_detection-0.2.0 → closed_loop_default_detection-0.3.0}/tests/test_synthetic.py +0 -0
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: closed-loop-default-detection
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.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
|
-
Author
|
|
5
|
+
Author: Hossain Pazooki
|
|
6
6
|
License-Expression: MIT
|
|
7
7
|
Project-URL: Homepage, https://github.com/hossainpazooki/closed-loop-default-detection
|
|
8
8
|
Project-URL: Repository, https://github.com/hossainpazooki/closed-loop-default-detection
|
|
@@ -148,6 +148,35 @@ seed 42, 10% budget, severity 0.6): the lever buys **157 labels for $439,578**
|
|
|
148
148
|
in dollars a lender can see, on the only lever that buys it rather than reweighting what is
|
|
149
149
|
already identified.
|
|
150
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).
|
|
179
|
+
|
|
151
180
|
## Install
|
|
152
181
|
|
|
153
182
|
```bash
|
|
@@ -114,6 +114,35 @@ seed 42, 10% budget, severity 0.6): the lever buys **157 labels for $439,578**
|
|
|
114
114
|
in dollars a lender can see, on the only lever that buys it rather than reweighting what is
|
|
115
115
|
already identified.
|
|
116
116
|
|
|
117
|
+
## Pricing the feedback loop (v3)
|
|
118
|
+
|
|
119
|
+
`v0.3.0` prices the **dynamic** regime, where the model's own approvals create its next
|
|
120
|
+
training set: three arms (treatment = retrain each generation; **frozen** = generation-0
|
|
121
|
+
model deployed forever; **prior** = prior-policy funding), 25 spaced seeds × 3 severities ×
|
|
122
|
+
2 exploration rates × 12 generations = **450 runs**
|
|
123
|
+
(`artifacts/feedback_profit_sweep.csv`), with three pre-registered hypotheses tested as
|
|
124
|
+
within-seed paired contrasts on realized funded-book P&L at severity 0.4, Holm-corrected.
|
|
125
|
+
Every number below recomputes via `python scripts/feedback_sweep_stats.py`. All of it
|
|
126
|
+
consumes **planted, risk-unlinked default timing** — a verified *experiment*, not a
|
|
127
|
+
verified *result*.
|
|
128
|
+
|
|
129
|
+
| Hypothesis (severity 0.4, gens 1–11) | Median paired deficit | Sign | Verdict |
|
|
130
|
+
|---|---|---|---|
|
|
131
|
+
| H1 feedback accumulation costs money (treatment − frozen, ε=0) | **−0.0028** | 24/25 | **confirmed** (p_holm 2.3e-06, clears the 0.0018 noise floor) |
|
|
132
|
+
| H2 the policy switch costs money (frozen − prior, ε=0) | +0.0119 | 0/25 | **not confirmed — measured opposite in direction** |
|
|
133
|
+
| H3 exploration buys profit back (treatment ε=.05 − ε=0) | −0.0024 | 0/25 | **not confirmed** |
|
|
134
|
+
|
|
135
|
+
The decomposition v3 exists for came back one-for-three: letting the loop close on its own
|
|
136
|
+
labels measurably costs the book (H1), but at these severities the *switch* to model-based
|
|
137
|
+
funding is profitable relative to the prior policy (H2's predicted sign was wrong — reported
|
|
138
|
+
as measured, not softened), and 5% exploration does not pay for itself in-window (H3; the
|
|
139
|
+
policy-book-only secondary H3a is negative too). The **H4 integrity control** — frozen-arm
|
|
140
|
+
paired difference must equal the explored slice's own P&L *exactly* — holds at ≤1e-10 across
|
|
141
|
+
all 900 checked rows, and the answer to the v2-banked question is re-verified at 25 seeds:
|
|
142
|
+
the declined-ECE alarm fires at generation 1–2 in every ε=0 run (severity 0.4: 25/25 at
|
|
143
|
+
generation 1). Full mechanics in [`docs/how-it-works.md`](docs/how-it-works.md); gates in
|
|
144
|
+
[`docs/validation.md`](docs/validation.md).
|
|
145
|
+
|
|
117
146
|
## Install
|
|
118
147
|
|
|
119
148
|
```bash
|
|
@@ -4,13 +4,13 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "closed-loop-default-detection"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.3.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"
|
|
11
11
|
license = "MIT"
|
|
12
12
|
license-files = ["LICENSE"]
|
|
13
|
-
authors = [{ name = "Hossain Pazooki"
|
|
13
|
+
authors = [{ name = "Hossain Pazooki" }]
|
|
14
14
|
keywords = [
|
|
15
15
|
"selective-labels",
|
|
16
16
|
"probability-of-default",
|
|
@@ -49,6 +49,7 @@ __all__ = [
|
|
|
49
49
|
"emp_harness",
|
|
50
50
|
"loss_fractions",
|
|
51
51
|
"realized_profits",
|
|
52
|
+
"realized_book_profit",
|
|
52
53
|
]
|
|
53
54
|
|
|
54
55
|
|
|
@@ -304,6 +305,52 @@ def realized_profits(
|
|
|
304
305
|
return profit
|
|
305
306
|
|
|
306
307
|
|
|
308
|
+
def realized_book_profit(
|
|
309
|
+
y_true: np.ndarray,
|
|
310
|
+
default_day: np.ndarray | None,
|
|
311
|
+
requested_amount: np.ndarray,
|
|
312
|
+
funded: np.ndarray,
|
|
313
|
+
economics: LoanEconomics | None = None,
|
|
314
|
+
) -> float | None:
|
|
315
|
+
"""Realized book P&L of the funded rows, normalized by full-cohort exposure.
|
|
316
|
+
|
|
317
|
+
Reuses :func:`realized_profits` priced over the FULL cohort, then sums the
|
|
318
|
+
funded rows -- so day-90 rows take the decision-5 mean-body imputation with
|
|
319
|
+
the mean computed from the *cohort's* body defaulters (lambda = 1 fallback
|
|
320
|
+
when the cohort body is empty, see :func:`loss_fractions`). The basis is a
|
|
321
|
+
planted cohort property, invariant to who funds: pricing on the funded
|
|
322
|
+
slice's own body mean made book P&L non-additive across slices and broke
|
|
323
|
+
the H4 arithmetic identity at the pilot gate (spec Rev 2.1 amendment,
|
|
324
|
+
2026-07-19). Returns ``None`` when timing columns are absent (flat cohorts,
|
|
325
|
+
``default_day is None``), mirroring :func:`emp_harness`. **SCM-only, like
|
|
326
|
+
everything in v3.**
|
|
327
|
+
|
|
328
|
+
Units: total realized profit of funded rows / (n_applicants * mean
|
|
329
|
+
``requested_amount`` over the FULL cohort, not funded-only) -- profit per
|
|
330
|
+
applicant as a fraction of cohort mean exposure (v2 decision 6 extended);
|
|
331
|
+
the full-cohort denominator keeps arms with different funded counts
|
|
332
|
+
comparable and paired differences well-defined.
|
|
333
|
+
|
|
334
|
+
Pure numpy, zero RNG, deterministic.
|
|
335
|
+
"""
|
|
336
|
+
if default_day is None:
|
|
337
|
+
return None
|
|
338
|
+
|
|
339
|
+
y_true = np.asarray(y_true, dtype=float)
|
|
340
|
+
default_day = np.asarray(default_day, dtype=float)
|
|
341
|
+
requested_amount = np.asarray(requested_amount, dtype=float)
|
|
342
|
+
funded = np.asarray(funded, dtype=bool)
|
|
343
|
+
n = y_true.shape[0]
|
|
344
|
+
|
|
345
|
+
mean_a = float(np.mean(requested_amount)) if n > 0 else 0.0
|
|
346
|
+
denom = n * mean_a
|
|
347
|
+
if denom == 0.0:
|
|
348
|
+
return float("nan")
|
|
349
|
+
|
|
350
|
+
profits = realized_profits(y_true, default_day, requested_amount, economics)
|
|
351
|
+
return float(np.sum(profits[funded])) / denom
|
|
352
|
+
|
|
353
|
+
|
|
307
354
|
def emp_harness(
|
|
308
355
|
y_true: np.ndarray,
|
|
309
356
|
scores: np.ndarray,
|
{closed_loop_default_detection-0.2.0 → closed_loop_default_detection-0.3.0}/src/cldd/feedback.py
RENAMED
|
@@ -23,6 +23,15 @@ 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,
|
|
27
|
+
byte-exact at their defaults, that isolate the trajectory's causes into three
|
|
28
|
+
arms: ``retrain=False`` freezes the generation-0 model forever (isolates
|
|
29
|
+
feedback accumulation — the "frozen" arm), and ``policy_mode="prior"`` funds
|
|
30
|
+
every generation via the cohort's own prior-policy ``approved`` column while
|
|
31
|
+
the model still trains for its metrics (the noise-floor "prior" arm).
|
|
32
|
+
``retrain=True, policy_mode="model"`` (the defaults) is today's "treatment"
|
|
33
|
+
behavior, unchanged.
|
|
34
|
+
|
|
26
35
|
Per generation we record the planted-truth calibration on the declined (never
|
|
27
36
|
labeled) side, the *observable* funded-book performance, and the observable
|
|
28
37
|
positivity diagnostics — so the harness can show whether the loop's blind spot
|
|
@@ -39,7 +48,7 @@ from dataclasses import dataclass, field
|
|
|
39
48
|
|
|
40
49
|
import numpy as np
|
|
41
50
|
|
|
42
|
-
from . import config, eval_default, model_pd
|
|
51
|
+
from . import config, emp, eval_default, model_pd
|
|
43
52
|
from .diagnostics import PositivityDiagnostics, positivity_diagnostics
|
|
44
53
|
from .loop import LeverMetrics, make_generator
|
|
45
54
|
|
|
@@ -64,6 +73,14 @@ class GenerationResult:
|
|
|
64
73
|
diagnostics: PositivityDiagnostics
|
|
65
74
|
n_explored: int = 0
|
|
66
75
|
explored_defaults: int = 0
|
|
76
|
+
#: Realized funded-book P&L (v3), per applicant as a fraction of the FULL
|
|
77
|
+
#: cohort's mean ``requested_amount`` -- see ``emp.realized_book_profit``.
|
|
78
|
+
#: ``None`` when the cohort carries no planted default timing (flat).
|
|
79
|
+
book_profit: float | None = None
|
|
80
|
+
#: Realized P&L of the explored slice alone, same full-cohort denominator
|
|
81
|
+
#: as ``book_profit``. ``0.0`` when nothing was explored this generation;
|
|
82
|
+
#: ``None`` under the same no-timing condition as ``book_profit``.
|
|
83
|
+
explored_profit: float | None = None
|
|
67
84
|
|
|
68
85
|
|
|
69
86
|
@dataclass
|
|
@@ -94,11 +111,15 @@ class FeedbackLoop:
|
|
|
94
111
|
approval_rate: float = config.DEFAULT_APPROVAL_RATE,
|
|
95
112
|
seed: int = config.RANDOM_SEED,
|
|
96
113
|
policy_threshold: float = config.POLICY_PD_THRESHOLD,
|
|
114
|
+
retrain: bool = True,
|
|
115
|
+
policy_mode: str = "model",
|
|
97
116
|
) -> None:
|
|
98
117
|
if not 0.0 <= exploration_rate < 1.0:
|
|
99
118
|
raise ValueError(f"exploration_rate must be in [0, 1); got {exploration_rate!r}")
|
|
100
119
|
if n_generations < 1:
|
|
101
120
|
raise ValueError(f"n_generations must be >= 1; got {n_generations!r}")
|
|
121
|
+
if policy_mode not in ("model", "prior"):
|
|
122
|
+
raise ValueError(f"policy_mode must be 'model' or 'prior'; got {policy_mode!r}")
|
|
102
123
|
self.selection_severity = selection_severity
|
|
103
124
|
self.n_generations = n_generations
|
|
104
125
|
self.exploration_rate = exploration_rate
|
|
@@ -107,6 +128,15 @@ class FeedbackLoop:
|
|
|
107
128
|
self.approval_rate = approval_rate
|
|
108
129
|
self.seed = seed
|
|
109
130
|
self.policy_threshold = policy_threshold
|
|
131
|
+
#: ``False`` (v3 "frozen" arm): keep the generation-0 model deployed
|
|
132
|
+
#: unchanged forever -- no generation >= 1 ever calls
|
|
133
|
+
#: ``model_pd.train_pd_model`` again. Byte-exact default is ``True``.
|
|
134
|
+
self.retrain = retrain
|
|
135
|
+
#: ``"prior"`` (v3 "prior" arm): fund every generation via the
|
|
136
|
+
#: cohort's own prior-policy ``approved`` column; the model still
|
|
137
|
+
#: trains each generation (for its metrics) but never decides
|
|
138
|
+
#: funding. Byte-exact default is ``"model"`` (today's behavior).
|
|
139
|
+
self.policy_mode = policy_mode
|
|
110
140
|
|
|
111
141
|
# ------------------------------------------------------------------ #
|
|
112
142
|
|
|
@@ -152,29 +182,54 @@ class FeedbackLoop:
|
|
|
152
182
|
X = cohort["features"].to_numpy(dtype=float)
|
|
153
183
|
y = cohort["true_default"]
|
|
154
184
|
|
|
155
|
-
|
|
185
|
+
# ``policy`` label semantics are unchanged by ``policy_mode``: it
|
|
186
|
+
# reflects whether a model has been trained yet (generation 0 is
|
|
187
|
+
# always "prior"; generation >= 1 is always "model"), even when
|
|
188
|
+
# policy_mode="prior" means the model isn't the one FUNDING.
|
|
189
|
+
policy = "prior" if model is None else "model"
|
|
190
|
+
|
|
191
|
+
if self.policy_mode == "prior":
|
|
192
|
+
policy_funded = cohort["approved"]
|
|
193
|
+
elif model is None:
|
|
156
194
|
policy_funded = cohort["approved"]
|
|
157
|
-
policy = "prior"
|
|
158
195
|
else:
|
|
159
196
|
policy_funded = self._model_policy(model, X)
|
|
160
|
-
policy = "model"
|
|
161
197
|
|
|
162
|
-
|
|
198
|
+
# Frozen arm: gen-0 training must be eps-invariant (the H4
|
|
199
|
+
# identity requires the deployed model itself not to depend on
|
|
200
|
+
# eps), so exploration is suppressed at generation 0 only when
|
|
201
|
+
# retrain=False. This never fires on the default retrain=True
|
|
202
|
+
# path -- the EXPLORE_STREAM_FEEDBACK draw and its call site are
|
|
203
|
+
# untouched there.
|
|
204
|
+
if not self.retrain and generation == 0:
|
|
205
|
+
explored = np.zeros_like(policy_funded)
|
|
206
|
+
else:
|
|
207
|
+
explored = self._explore(policy_funded, generation)
|
|
163
208
|
funded = policy_funded | explored
|
|
164
209
|
|
|
165
210
|
# Diagnose the POLICY selection (pre-exploration): that is the
|
|
166
211
|
# selection a deployment would be monitoring.
|
|
167
212
|
diag = positivity_diagnostics(X, policy_funded, random_state=self.seed + generation)
|
|
168
213
|
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
214
|
+
# retrain=True (default): train every generation, byte-identical
|
|
215
|
+
# to pre-v3 behavior. retrain=False (frozen arm): train only once,
|
|
216
|
+
# at generation 0 (model is None), then reuse it forever.
|
|
217
|
+
if self.retrain or model is None:
|
|
218
|
+
if self.exploration_rate > 0.0:
|
|
219
|
+
weights = np.where(explored, 1.0 / self.exploration_rate, 1.0)[funded]
|
|
220
|
+
else:
|
|
221
|
+
weights = None
|
|
222
|
+
model = model_pd.train_pd_model(
|
|
223
|
+
X[funded], y[funded], sample_weight=weights, random_state=self.seed + generation
|
|
224
|
+
)
|
|
176
225
|
|
|
177
226
|
scored = eval_default.score_pd_detection(model, cohort, self.policy_threshold, funded=funded)
|
|
227
|
+
|
|
228
|
+
default_day = cohort.get("days_to_default")
|
|
229
|
+
requested_amount = cohort["features"]["requested_amount"].to_numpy(dtype=float)
|
|
230
|
+
book_profit = emp.realized_book_profit(y, default_day, requested_amount, funded)
|
|
231
|
+
explored_profit = emp.realized_book_profit(y, default_day, requested_amount, explored)
|
|
232
|
+
|
|
178
233
|
result.generations.append(
|
|
179
234
|
GenerationResult(
|
|
180
235
|
generation=generation,
|
|
@@ -185,6 +240,8 @@ class FeedbackLoop:
|
|
|
185
240
|
diagnostics=diag,
|
|
186
241
|
n_explored=int(explored.sum()),
|
|
187
242
|
explored_defaults=int(y[explored].sum()),
|
|
243
|
+
book_profit=book_profit,
|
|
244
|
+
explored_profit=explored_profit,
|
|
188
245
|
)
|
|
189
246
|
)
|
|
190
247
|
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: closed-loop-default-detection
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.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
|
-
Author
|
|
5
|
+
Author: Hossain Pazooki
|
|
6
6
|
License-Expression: MIT
|
|
7
7
|
Project-URL: Homepage, https://github.com/hossainpazooki/closed-loop-default-detection
|
|
8
8
|
Project-URL: Repository, https://github.com/hossainpazooki/closed-loop-default-detection
|
|
@@ -148,6 +148,35 @@ seed 42, 10% budget, severity 0.6): the lever buys **157 labels for $439,578**
|
|
|
148
148
|
in dollars a lender can see, on the only lever that buys it rather than reweighting what is
|
|
149
149
|
already identified.
|
|
150
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).
|
|
179
|
+
|
|
151
180
|
## Install
|
|
152
181
|
|
|
153
182
|
```bash
|
|
@@ -21,6 +21,7 @@ src/closed_loop_default_detection.egg-info/SOURCES.txt
|
|
|
21
21
|
src/closed_loop_default_detection.egg-info/dependency_links.txt
|
|
22
22
|
src/closed_loop_default_detection.egg-info/requires.txt
|
|
23
23
|
src/closed_loop_default_detection.egg-info/top_level.txt
|
|
24
|
+
tests/test_book_profit.py
|
|
24
25
|
tests/test_correctors.py
|
|
25
26
|
tests/test_counterfactual.py
|
|
26
27
|
tests/test_diagnostics.py
|
|
@@ -28,6 +29,7 @@ tests/test_emp.py
|
|
|
28
29
|
tests/test_eval_default.py
|
|
29
30
|
tests/test_exploration.py
|
|
30
31
|
tests/test_feedback.py
|
|
32
|
+
tests/test_feedback_flags.py
|
|
31
33
|
tests/test_fidelity.py
|
|
32
34
|
tests/test_fidelity_report.py
|
|
33
35
|
tests/test_loop.py
|
|
@@ -36,4 +38,5 @@ tests/test_model_pd.py
|
|
|
36
38
|
tests/test_reject_inference.py
|
|
37
39
|
tests/test_scm_gating.py
|
|
38
40
|
tests/test_sklearn_compat.py
|
|
41
|
+
tests/test_sweep_stats.py
|
|
39
42
|
tests/test_synthetic.py
|