closed-loop-default-detection 0.1.0__py3-none-any.whl
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.
- cldd/__init__.py +96 -0
- cldd/config.py +94 -0
- cldd/correctors.py +172 -0
- cldd/counterfactual.py +767 -0
- cldd/diagnostics.py +107 -0
- cldd/eval_default.py +161 -0
- cldd/feedback.py +191 -0
- cldd/fidelity.py +430 -0
- cldd/loop.py +379 -0
- cldd/model_pd.py +194 -0
- cldd/py.typed +1 -0
- cldd/reject_inference.py +264 -0
- cldd/scm.py +1010 -0
- cldd/synthetic.py +256 -0
- closed_loop_default_detection-0.1.0.dist-info/METADATA +218 -0
- closed_loop_default_detection-0.1.0.dist-info/RECORD +19 -0
- closed_loop_default_detection-0.1.0.dist-info/WHEEL +5 -0
- closed_loop_default_detection-0.1.0.dist-info/licenses/LICENSE +21 -0
- closed_loop_default_detection-0.1.0.dist-info/top_level.txt +1 -0
cldd/diagnostics.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Deployable positivity/overlap diagnostics — the observable frontier signal.
|
|
2
|
+
|
|
3
|
+
Lesson from the hackathon harness (FABLE.md): the operating frontier was measured
|
|
4
|
+
against *planted* truth (declined-cohort ECE), which a real lender can never
|
|
5
|
+
compute — so the frontier, as measured, is not deployable knowledge. What a
|
|
6
|
+
lender CAN compute is how pathological the selection itself looks:
|
|
7
|
+
|
|
8
|
+
* **propensity separability** — fit P(funded | features) and take its in-sample
|
|
9
|
+
AUC. Near 0.5 the funded and unfunded pools overlap (IPW has support to work
|
|
10
|
+
with); near 1.0 the policy is deterministic in the features and positivity is
|
|
11
|
+
gone by construction.
|
|
12
|
+
* **IPW weight degeneracy** — the effective sample size of the 1/propensity
|
|
13
|
+
weights actually used by the reweight lever, as a fraction of the funded count.
|
|
14
|
+
When a few rows carry all the weight, the "corrected" estimate is resting on
|
|
15
|
+
almost no data.
|
|
16
|
+
* **unreachable region** — the share of unfunded rows whose propensity sits at
|
|
17
|
+
or below the clip floor: the part of the declined population the funded sample
|
|
18
|
+
cannot speak for at all.
|
|
19
|
+
|
|
20
|
+
None of these need a single declined-row label. The harness validates them
|
|
21
|
+
against the hidden truth (see ``test_diagnostics`` and the frontier artifacts):
|
|
22
|
+
inside the measured frontier they look healthy, beyond it they deteriorate — so
|
|
23
|
+
a real deployment can use them as an abstention trigger where this harness used
|
|
24
|
+
planted ground truth.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
from dataclasses import dataclass
|
|
30
|
+
|
|
31
|
+
import numpy as np
|
|
32
|
+
from sklearn.metrics import roc_auc_score
|
|
33
|
+
|
|
34
|
+
from . import config, model_pd
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class PositivityDiagnostics:
|
|
39
|
+
"""Observable-only overlap health for one cohort's funding selection."""
|
|
40
|
+
|
|
41
|
+
#: In-sample AUC of P(funded | features). ~0.5 = overlapping pools,
|
|
42
|
+
#: ~1.0 = selection deterministic in the features (positivity broken).
|
|
43
|
+
propensity_auc: float
|
|
44
|
+
#: Effective sample size of the clipped 1/propensity weights on funded rows,
|
|
45
|
+
#: divided by the funded count. 1.0 = uniform weights; small = degenerate.
|
|
46
|
+
ess_ratio: float
|
|
47
|
+
#: Share of unfunded rows with raw propensity <= the clip floor — the region
|
|
48
|
+
#: the funded sample cannot represent.
|
|
49
|
+
unfunded_below_floor: float
|
|
50
|
+
#: Observable abstention signal: True when any component crosses its
|
|
51
|
+
#: configured threshold (calibrated on this harness — see config).
|
|
52
|
+
flagged: bool
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def positivity_diagnostics(
|
|
56
|
+
X,
|
|
57
|
+
funded,
|
|
58
|
+
*,
|
|
59
|
+
random_state: int | None = None,
|
|
60
|
+
clip: tuple[float, float] = (0.05, 0.95),
|
|
61
|
+
auc_max: float | None = None,
|
|
62
|
+
ess_min: float | None = None,
|
|
63
|
+
below_floor_max: float | None = None,
|
|
64
|
+
) -> PositivityDiagnostics:
|
|
65
|
+
"""Compute observable positivity diagnostics for a funding selection.
|
|
66
|
+
|
|
67
|
+
Mirrors :func:`cldd.model_pd.selection_adjusted_weights` (same classifier,
|
|
68
|
+
same clip) so the ESS describes the weights the IPW lever would actually use.
|
|
69
|
+
Thresholds default to the config values; pass overrides for sweeps.
|
|
70
|
+
"""
|
|
71
|
+
X = np.asarray(X, dtype=float)
|
|
72
|
+
funded = np.asarray(funded).astype(bool)
|
|
73
|
+
rs = config.RANDOM_SEED if random_state is None else random_state
|
|
74
|
+
auc_max = config.DIAG_PROPENSITY_AUC_MAX if auc_max is None else auc_max
|
|
75
|
+
ess_min = config.DIAG_ESS_RATIO_MIN if ess_min is None else ess_min
|
|
76
|
+
below_floor_max = (
|
|
77
|
+
config.DIAG_UNFUNDED_BELOW_FLOOR_MAX if below_floor_max is None else below_floor_max
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
n_funded = int(funded.sum())
|
|
81
|
+
n_unfunded = int((~funded).sum())
|
|
82
|
+
if n_funded == 0 or n_unfunded == 0:
|
|
83
|
+
# Degenerate selection: nothing to compare against — abstain.
|
|
84
|
+
return PositivityDiagnostics(
|
|
85
|
+
propensity_auc=float("nan"),
|
|
86
|
+
ess_ratio=float("nan"),
|
|
87
|
+
unfunded_below_floor=float("nan"),
|
|
88
|
+
flagged=True,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
clf = model_pd._new_classifier(rs)
|
|
92
|
+
clf.fit(X, funded.astype(int))
|
|
93
|
+
propensity = clf.predict_proba(X)[:, 1]
|
|
94
|
+
|
|
95
|
+
auc = float(roc_auc_score(funded.astype(int), propensity))
|
|
96
|
+
below_floor = float((propensity[~funded] <= clip[0]).mean())
|
|
97
|
+
|
|
98
|
+
w = 1.0 / np.clip(propensity[funded], clip[0], clip[1])
|
|
99
|
+
ess_ratio = float((w.sum() ** 2) / (w**2).sum() / n_funded)
|
|
100
|
+
|
|
101
|
+
flagged = (auc > auc_max) or (ess_ratio < ess_min) or (below_floor > below_floor_max)
|
|
102
|
+
return PositivityDiagnostics(
|
|
103
|
+
propensity_auc=auc,
|
|
104
|
+
ess_ratio=ess_ratio,
|
|
105
|
+
unfunded_below_floor=below_floor,
|
|
106
|
+
flagged=bool(flagged),
|
|
107
|
+
)
|
cldd/eval_default.py
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""Measurement edge of the closed loop (the **measure** stage).
|
|
2
|
+
|
|
3
|
+
Train the PD model on the *observed* (approved) rows only — exactly what a real
|
|
4
|
+
lender has — then score it against the planted ground truth across three
|
|
5
|
+
subpopulations: **all / approved / declined**. The declined subpopulation is the
|
|
6
|
+
headline: it is the part of the applicant pool whose outcomes real data can never
|
|
7
|
+
reveal, so its calibration error (ECE) is the signal the loop optimizes and the
|
|
8
|
+
selection bias it exposes.
|
|
9
|
+
|
|
10
|
+
The reusable pieces here (``fit_observed_model`` and ``score_pd_detection``) are
|
|
11
|
+
shared with ``loop.py`` so the "measure" and "improve" stages score detection
|
|
12
|
+
identically — mirroring the building blocks shared in
|
|
13
|
+
``upstream-label-correction/evals/mislabel_detection.py``.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
|
|
20
|
+
import numpy as np
|
|
21
|
+
from sklearn.metrics import brier_score_loss, roc_auc_score
|
|
22
|
+
|
|
23
|
+
from . import config, model_pd
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class SubgroupMetrics:
|
|
28
|
+
"""Detection/calibration metrics on one subpopulation."""
|
|
29
|
+
|
|
30
|
+
n: int
|
|
31
|
+
base_rate: float # true default fraction in the subgroup
|
|
32
|
+
mean_pd: float # mean predicted PD (vs base_rate => bias direction)
|
|
33
|
+
auc: float
|
|
34
|
+
brier: float
|
|
35
|
+
ece: float
|
|
36
|
+
f1: float
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class PdDetectionResult:
|
|
41
|
+
"""Result of scoring one model on one cohort (local analog of EvalResult)."""
|
|
42
|
+
|
|
43
|
+
name: str
|
|
44
|
+
passed: bool
|
|
45
|
+
score: float # headline: declined-subpopulation ECE (lower better)
|
|
46
|
+
threshold: float
|
|
47
|
+
details: dict = field(default_factory=dict)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def expected_calibration_error(y_true: np.ndarray, p: np.ndarray, n_bins: int = 10) -> float:
|
|
51
|
+
"""Standard ECE: mean over equal-width PD bins of ``|accuracy - confidence|``."""
|
|
52
|
+
y_true = np.asarray(y_true, dtype=float)
|
|
53
|
+
p = np.asarray(p, dtype=float)
|
|
54
|
+
if len(p) == 0:
|
|
55
|
+
return float("nan")
|
|
56
|
+
bins = np.linspace(0.0, 1.0, n_bins + 1)
|
|
57
|
+
bin_idx = np.digitize(p, bins[1:-1]) # 0..n_bins-1
|
|
58
|
+
ece = 0.0
|
|
59
|
+
for b in range(n_bins):
|
|
60
|
+
m = bin_idx == b
|
|
61
|
+
count = int(m.sum())
|
|
62
|
+
if count == 0:
|
|
63
|
+
continue
|
|
64
|
+
ece += (count / len(p)) * abs(float(y_true[m].mean()) - float(p[m].mean()))
|
|
65
|
+
return float(ece)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _f1_at_threshold(y_true: np.ndarray, p: np.ndarray, threshold: float) -> float:
|
|
69
|
+
pred = (np.asarray(p) >= threshold).astype(int)
|
|
70
|
+
yt = np.asarray(y_true).astype(int)
|
|
71
|
+
tp = int(((pred == 1) & (yt == 1)).sum())
|
|
72
|
+
fp = int(((pred == 1) & (yt == 0)).sum())
|
|
73
|
+
fn = int(((pred == 0) & (yt == 1)).sum())
|
|
74
|
+
precision = tp / (tp + fp) if (tp + fp) else 0.0
|
|
75
|
+
recall = tp / (tp + fn) if (tp + fn) else 0.0
|
|
76
|
+
return float(2 * precision * recall / (precision + recall)) if (precision + recall) else 0.0
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _subgroup_metrics(y_true: np.ndarray, p: np.ndarray, threshold: float) -> SubgroupMetrics:
|
|
80
|
+
y_true = np.asarray(y_true, dtype=int)
|
|
81
|
+
p = np.asarray(p, dtype=float)
|
|
82
|
+
n = len(y_true)
|
|
83
|
+
if n == 0:
|
|
84
|
+
return SubgroupMetrics(0, float("nan"), float("nan"), float("nan"), float("nan"), float("nan"), float("nan"))
|
|
85
|
+
try:
|
|
86
|
+
auc = float(roc_auc_score(y_true, p)) if len(np.unique(y_true)) > 1 else float("nan")
|
|
87
|
+
except ValueError:
|
|
88
|
+
auc = float("nan")
|
|
89
|
+
return SubgroupMetrics(
|
|
90
|
+
n=n,
|
|
91
|
+
base_rate=float(y_true.mean()),
|
|
92
|
+
mean_pd=float(p.mean()),
|
|
93
|
+
auc=auc,
|
|
94
|
+
brier=float(brier_score_loss(y_true, p)) if len(np.unique(y_true)) > 1 else float("nan"),
|
|
95
|
+
ece=expected_calibration_error(y_true, p),
|
|
96
|
+
f1=_f1_at_threshold(y_true, p, threshold),
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def fit_observed_model(cohort: dict, sample_weight=None, random_state: int | None = None) -> model_pd.CalibratedPDModel:
|
|
101
|
+
"""Fit the PD model on the cohort's **approved** rows only (selective labels).
|
|
102
|
+
|
|
103
|
+
``sample_weight``, when given, must already be aligned to the approved rows
|
|
104
|
+
(length == ``cohort['approved'].sum()``) — the loop passes the approved slice
|
|
105
|
+
of the IPW weights here.
|
|
106
|
+
"""
|
|
107
|
+
X = cohort["features"].to_numpy(dtype=float)
|
|
108
|
+
approved = cohort["approved"]
|
|
109
|
+
return model_pd.train_pd_model(
|
|
110
|
+
X[approved],
|
|
111
|
+
cohort["true_default"][approved],
|
|
112
|
+
sample_weight=sample_weight,
|
|
113
|
+
random_state=random_state,
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def score_pd_detection(
|
|
118
|
+
model: model_pd.CalibratedPDModel,
|
|
119
|
+
cohort: dict,
|
|
120
|
+
threshold: float | None = None,
|
|
121
|
+
funded=None,
|
|
122
|
+
) -> dict:
|
|
123
|
+
"""Score ``model`` on the full cohort vs planted truth, split by subpopulation.
|
|
124
|
+
|
|
125
|
+
Returns ``{'all', 'approved', 'declined'}`` -> :class:`SubgroupMetrics`, plus
|
|
126
|
+
the raw ``predictions``. ``funded`` overrides the subgrouping mask (default:
|
|
127
|
+
the cohort's prior-policy ``approved``) — the exploration lever and the
|
|
128
|
+
feedback loop pass the mask of rows that were *actually* funded, so
|
|
129
|
+
``'declined'`` is exactly the never-labeled (out-of-training) population.
|
|
130
|
+
"""
|
|
131
|
+
threshold = config.POLICY_PD_THRESHOLD if threshold is None else threshold
|
|
132
|
+
X = cohort["features"].to_numpy(dtype=float)
|
|
133
|
+
p = model_pd.predict_pd(model, X)
|
|
134
|
+
y = cohort["true_default"]
|
|
135
|
+
approved = cohort["approved"] if funded is None else np.asarray(funded, dtype=bool)
|
|
136
|
+
declined = ~approved
|
|
137
|
+
return {
|
|
138
|
+
"all": _subgroup_metrics(y, p, threshold),
|
|
139
|
+
"approved": _subgroup_metrics(y[approved], p[approved], threshold),
|
|
140
|
+
"declined": _subgroup_metrics(y[declined], p[declined], threshold),
|
|
141
|
+
"predictions": p,
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def evaluate_naive(cohort: dict, threshold: float | None = None, target_ece: float | None = None) -> PdDetectionResult:
|
|
146
|
+
"""Convenience: train-on-observed (no correction) and score, as a PASS/FAIL result.
|
|
147
|
+
|
|
148
|
+
PASS when declined-subpopulation ECE <= ``target_ece``.
|
|
149
|
+
"""
|
|
150
|
+
threshold = config.POLICY_PD_THRESHOLD if threshold is None else threshold
|
|
151
|
+
target_ece = config.TARGET_DECLINED_ECE if target_ece is None else target_ece
|
|
152
|
+
model = fit_observed_model(cohort)
|
|
153
|
+
scored = score_pd_detection(model, cohort, threshold)
|
|
154
|
+
declined_ece = scored["declined"].ece
|
|
155
|
+
return PdDetectionResult(
|
|
156
|
+
name="pd_default_detection",
|
|
157
|
+
passed=declined_ece <= target_ece,
|
|
158
|
+
score=declined_ece,
|
|
159
|
+
threshold=threshold,
|
|
160
|
+
details={"all": scored["all"], "approved": scored["approved"], "declined": scored["declined"]},
|
|
161
|
+
)
|
cldd/feedback.py
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""Closing the loop for real: the model's own decisions create its training data.
|
|
2
|
+
|
|
3
|
+
``SelectiveLabelsLoop`` measures a *static* question — how well levers correct
|
|
4
|
+
selection imposed by a fixed prior policy. But the lesson of the hackathon
|
|
5
|
+
harness (FABLE.md) is dynamic: once a PD model is deployed, **its own approvals
|
|
6
|
+
decide which outcomes the next model generation gets to learn from**. That
|
|
7
|
+
feedback has a structural property no severity knob reaches: a deterministic
|
|
8
|
+
model policy (fund the lowest-PD fraction) makes selection a *function* of the
|
|
9
|
+
features, so the funded and declined pools stop overlapping **by construction**
|
|
10
|
+
— positivity is not merely strained, it is gone, and every observational
|
|
11
|
+
correction (IPW, retrain) is undefined on the declined side.
|
|
12
|
+
|
|
13
|
+
``FeedbackLoop`` simulates that regime:
|
|
14
|
+
|
|
15
|
+
* **Generation 0** funds via the prior underwriter policy at the configured
|
|
16
|
+
``selection_severity`` (exactly the world the static loop measures) and trains
|
|
17
|
+
the first model on those labels.
|
|
18
|
+
* **Generation t >= 1** draws a *fresh* applicant cohort, funds the
|
|
19
|
+
``approval_rate`` fraction the previous generation's model scores safest,
|
|
20
|
+
observes outcomes for funded rows only, and trains the next model on them.
|
|
21
|
+
* With ``exploration_rate=eps > 0``, each generation additionally funds a random
|
|
22
|
+
``eps``-fraction of its declines (dedicated RNG stream) and trains with exact
|
|
23
|
+
labeled-propensity weights — the identification-buying lever from the static
|
|
24
|
+
loop, now acting as a *stabilizer* of the feedback dynamics.
|
|
25
|
+
|
|
26
|
+
Per generation we record the planted-truth calibration on the declined (never
|
|
27
|
+
labeled) side, the *observable* funded-book performance, and the observable
|
|
28
|
+
positivity diagnostics — so the harness can show whether the loop's blind spot
|
|
29
|
+
grows while everything a real lender watches still looks healthy.
|
|
30
|
+
|
|
31
|
+
Design choice: each generation trains on its own cohort's funded rows only (no
|
|
32
|
+
label accumulation across generations). This isolates the selection mechanism —
|
|
33
|
+
any drift is attributable to *who got funded*, not to a growing training set.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
from __future__ import annotations
|
|
37
|
+
|
|
38
|
+
from dataclasses import dataclass, field
|
|
39
|
+
|
|
40
|
+
import numpy as np
|
|
41
|
+
|
|
42
|
+
from . import config, eval_default, model_pd
|
|
43
|
+
from .diagnostics import PositivityDiagnostics, positivity_diagnostics
|
|
44
|
+
from .loop import LeverMetrics, make_generator
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class GenerationResult:
|
|
49
|
+
"""One generation of the deployed-model feedback simulation."""
|
|
50
|
+
|
|
51
|
+
generation: int
|
|
52
|
+
#: ``"prior"`` (generation 0) or ``"model"`` (the previous generation's model
|
|
53
|
+
#: made the funding decisions).
|
|
54
|
+
policy: str
|
|
55
|
+
funded_rate: float
|
|
56
|
+
#: Observable portfolio performance: realized default rate of the funded book
|
|
57
|
+
#: (including explored loans — they were funded too).
|
|
58
|
+
funded_default_rate: float
|
|
59
|
+
#: Planted-truth metrics for this generation's model on ITS OWN blind spot —
|
|
60
|
+
#: the rows its training never saw. ``declined_*`` = unfunded rows.
|
|
61
|
+
metrics: LeverMetrics
|
|
62
|
+
#: Observable positivity diagnostics of the *policy* selection (before
|
|
63
|
+
#: exploration), i.e. what monitoring would see.
|
|
64
|
+
diagnostics: PositivityDiagnostics
|
|
65
|
+
n_explored: int = 0
|
|
66
|
+
explored_defaults: int = 0
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass
|
|
70
|
+
class FeedbackResult:
|
|
71
|
+
"""Full trajectory of a feedback run."""
|
|
72
|
+
|
|
73
|
+
generations: list[GenerationResult] = field(default_factory=list)
|
|
74
|
+
selection_severity: float = 0.0
|
|
75
|
+
exploration_rate: float = 0.0
|
|
76
|
+
generator: str = "scm"
|
|
77
|
+
|
|
78
|
+
@property
|
|
79
|
+
def declined_ece_trajectory(self) -> list[float]:
|
|
80
|
+
return [g.metrics.declined_ece for g in self.generations]
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class FeedbackLoop:
|
|
84
|
+
"""Simulate model-in-the-loop selective labels across deployment generations."""
|
|
85
|
+
|
|
86
|
+
def __init__(
|
|
87
|
+
self,
|
|
88
|
+
*,
|
|
89
|
+
selection_severity: float = 0.4,
|
|
90
|
+
n_generations: int = 6,
|
|
91
|
+
exploration_rate: float = 0.0,
|
|
92
|
+
generator: str = "scm",
|
|
93
|
+
n_applicants: int = config.DEFAULT_N_APPLICANTS,
|
|
94
|
+
approval_rate: float = config.DEFAULT_APPROVAL_RATE,
|
|
95
|
+
seed: int = config.RANDOM_SEED,
|
|
96
|
+
policy_threshold: float = config.POLICY_PD_THRESHOLD,
|
|
97
|
+
) -> None:
|
|
98
|
+
if not 0.0 <= exploration_rate < 1.0:
|
|
99
|
+
raise ValueError(f"exploration_rate must be in [0, 1); got {exploration_rate!r}")
|
|
100
|
+
if n_generations < 1:
|
|
101
|
+
raise ValueError(f"n_generations must be >= 1; got {n_generations!r}")
|
|
102
|
+
self.selection_severity = selection_severity
|
|
103
|
+
self.n_generations = n_generations
|
|
104
|
+
self.exploration_rate = exploration_rate
|
|
105
|
+
self.generator = generator
|
|
106
|
+
self.n_applicants = n_applicants
|
|
107
|
+
self.approval_rate = approval_rate
|
|
108
|
+
self.seed = seed
|
|
109
|
+
self.policy_threshold = policy_threshold
|
|
110
|
+
|
|
111
|
+
# ------------------------------------------------------------------ #
|
|
112
|
+
|
|
113
|
+
def _model_policy(self, model: model_pd.CalibratedPDModel, X: np.ndarray) -> np.ndarray:
|
|
114
|
+
"""Deterministic deployed policy: fund exactly the ``approval_rate``
|
|
115
|
+
fraction with the lowest predicted PD.
|
|
116
|
+
|
|
117
|
+
Rank-based top-k rather than a quantile cutoff: the isotonic calibrator
|
|
118
|
+
makes predictions stepwise-constant, so ``p <= quantile(p, rate)`` can
|
|
119
|
+
fund far more than ``rate`` through ties (observed: 84% at rate 0.6).
|
|
120
|
+
``argsort(kind="stable")`` breaks ties by row order — deterministic.
|
|
121
|
+
"""
|
|
122
|
+
p = model_pd.predict_pd(model, X)
|
|
123
|
+
k = int(round(self.approval_rate * len(p)))
|
|
124
|
+
funded = np.zeros(len(p), dtype=bool)
|
|
125
|
+
funded[np.argsort(p, kind="stable")[:k]] = True
|
|
126
|
+
return funded
|
|
127
|
+
|
|
128
|
+
def _explore(self, policy_funded: np.ndarray, generation: int) -> np.ndarray:
|
|
129
|
+
if self.exploration_rate <= 0.0:
|
|
130
|
+
return np.zeros_like(policy_funded)
|
|
131
|
+
rng = np.random.default_rng([self.seed, generation, config.EXPLORE_STREAM_FEEDBACK])
|
|
132
|
+
return (~policy_funded) & (rng.random(policy_funded.shape[0]) < self.exploration_rate)
|
|
133
|
+
|
|
134
|
+
# ------------------------------------------------------------------ #
|
|
135
|
+
|
|
136
|
+
def run(self) -> FeedbackResult:
|
|
137
|
+
result = FeedbackResult(
|
|
138
|
+
selection_severity=self.selection_severity,
|
|
139
|
+
exploration_rate=self.exploration_rate,
|
|
140
|
+
generator=self.generator,
|
|
141
|
+
)
|
|
142
|
+
model: model_pd.CalibratedPDModel | None = None
|
|
143
|
+
|
|
144
|
+
for generation in range(self.n_generations):
|
|
145
|
+
cohort = make_generator(
|
|
146
|
+
self.generator,
|
|
147
|
+
severity=self.selection_severity,
|
|
148
|
+
seed=self.seed + generation,
|
|
149
|
+
n_applicants=self.n_applicants,
|
|
150
|
+
approval_rate=self.approval_rate,
|
|
151
|
+
).generate_cohort()
|
|
152
|
+
X = cohort["features"].to_numpy(dtype=float)
|
|
153
|
+
y = cohort["true_default"]
|
|
154
|
+
|
|
155
|
+
if model is None:
|
|
156
|
+
policy_funded = cohort["approved"]
|
|
157
|
+
policy = "prior"
|
|
158
|
+
else:
|
|
159
|
+
policy_funded = self._model_policy(model, X)
|
|
160
|
+
policy = "model"
|
|
161
|
+
|
|
162
|
+
explored = self._explore(policy_funded, generation)
|
|
163
|
+
funded = policy_funded | explored
|
|
164
|
+
|
|
165
|
+
# Diagnose the POLICY selection (pre-exploration): that is the
|
|
166
|
+
# selection a deployment would be monitoring.
|
|
167
|
+
diag = positivity_diagnostics(X, policy_funded, random_state=self.seed + generation)
|
|
168
|
+
|
|
169
|
+
if self.exploration_rate > 0.0:
|
|
170
|
+
weights = np.where(explored, 1.0 / self.exploration_rate, 1.0)[funded]
|
|
171
|
+
else:
|
|
172
|
+
weights = None
|
|
173
|
+
model = model_pd.train_pd_model(
|
|
174
|
+
X[funded], y[funded], sample_weight=weights, random_state=self.seed + generation
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
scored = eval_default.score_pd_detection(model, cohort, self.policy_threshold, funded=funded)
|
|
178
|
+
result.generations.append(
|
|
179
|
+
GenerationResult(
|
|
180
|
+
generation=generation,
|
|
181
|
+
policy=policy,
|
|
182
|
+
funded_rate=float(funded.mean()),
|
|
183
|
+
funded_default_rate=float(y[funded].mean()),
|
|
184
|
+
metrics=LeverMetrics.from_scored(scored),
|
|
185
|
+
diagnostics=diag,
|
|
186
|
+
n_explored=int(explored.sum()),
|
|
187
|
+
explored_defaults=int(y[explored].sum()),
|
|
188
|
+
)
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
return result
|