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/loop.py
ADDED
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
"""CLUE-style closed loop for selective-labels default detection.
|
|
2
|
+
|
|
3
|
+
Ports ``upstream-label-correction/clue/loop.py`` to the SMB lending domain. Each
|
|
4
|
+
round:
|
|
5
|
+
|
|
6
|
+
1. **Generate** a synthetic cohort at the round's ``selection_severity`` (planted
|
|
7
|
+
default ground truth for every applicant; outcomes hidden for declines). The
|
|
8
|
+
cohort generator is pluggable: ``generator="flat"`` (default) keeps the
|
|
9
|
+
original single-layer ``SyntheticBorrowerGenerator``; ``generator="scm"``
|
|
10
|
+
drives the loop with the fitted layered ``StructuralBorrowerGenerator``.
|
|
11
|
+
2. **Measure** the naive detector — PD model trained on the approved rows only —
|
|
12
|
+
against the planted truth, focusing on the **declined** subpopulation real
|
|
13
|
+
data can never score.
|
|
14
|
+
3. **Improve** via the configured ``improve_mode`` lever(s):
|
|
15
|
+
|
|
16
|
+
* ``"reweight"`` — inverse-propensity (IPW) reweighting of the approved
|
|
17
|
+
training rows to undo prior-approval selection bias.
|
|
18
|
+
* ``"retrain"`` — refit on a **disjoint** train cohort (same severity/geometry,
|
|
19
|
+
a seed offset by ``train_seed_offset``) and score on the held-out measure
|
|
20
|
+
cohort, so the metric is honest and never leaks.
|
|
21
|
+
* ``"both"`` — run both and report each.
|
|
22
|
+
|
|
23
|
+
Orthogonal to ``improve_mode``, ``exploration_rate=eps > 0`` adds the
|
|
24
|
+
**exploration lever**: approve a random ``eps``-fraction of the policy's
|
|
25
|
+
declines so their outcomes become observed, then train on all funded rows
|
|
26
|
+
with *exact* labeled-propensity weights (policy approvals are labeled with
|
|
27
|
+
probability 1, explored declines with probability ``eps``). Unlike the IPW
|
|
28
|
+
lever, these weights are known by construction rather than fitted, so the
|
|
29
|
+
unobserved confounder — the mechanism FABLE.md identifies behind both
|
|
30
|
+
frontier failures — cannot leak into them. This is the only lever that buys
|
|
31
|
+
*identification* instead of reweighting what is already identified, at an
|
|
32
|
+
explicit cost the lender can see: the explored loans' realized defaults.
|
|
33
|
+
|
|
34
|
+
4. **Feed back**: if the corrected detector still clears the target calibration
|
|
35
|
+
on declines, raise the severity to probe a harder selection regime real data
|
|
36
|
+
cannot reach; if it can no longer clear it, stop and report the detector's
|
|
37
|
+
**operating frontier** (the highest severity still passing).
|
|
38
|
+
|
|
39
|
+
Loop *control* is keyed on the declined-subpopulation ECE of the **primary**
|
|
40
|
+
lever — the applied corrector with the highest ``control_priority`` (built-ins:
|
|
41
|
+
explore 3 > reweight 2 > retrain 1 > naive 0; ties go to first-in-list); the
|
|
42
|
+
other levers are reported alongside. The levers themselves are pluggable
|
|
43
|
+
``Corrector`` objects (see :mod:`cldd.correctors`); ``improve_mode`` /
|
|
44
|
+
``exploration_rate`` simply build the default list. Everything is deterministic
|
|
45
|
+
for a given seed.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
from __future__ import annotations
|
|
49
|
+
|
|
50
|
+
from dataclasses import dataclass, field
|
|
51
|
+
|
|
52
|
+
from . import config
|
|
53
|
+
from .correctors import (
|
|
54
|
+
Corrector,
|
|
55
|
+
CorrectorContext,
|
|
56
|
+
DisjointRetrainCorrector,
|
|
57
|
+
ExplorationCorrector,
|
|
58
|
+
IPWReweightCorrector,
|
|
59
|
+
LeverMetrics,
|
|
60
|
+
NaiveCorrector,
|
|
61
|
+
)
|
|
62
|
+
from .diagnostics import PositivityDiagnostics, positivity_diagnostics
|
|
63
|
+
from .scm import StructuralBorrowerGenerator
|
|
64
|
+
from .synthetic import SyntheticBorrowerGenerator
|
|
65
|
+
|
|
66
|
+
# Re-exported so existing ``from cldd.loop import LeverMetrics`` keeps working;
|
|
67
|
+
# the dataclass itself now lives in :mod:`cldd.correctors`.
|
|
68
|
+
__all__ = [
|
|
69
|
+
"IMPROVE_MODES",
|
|
70
|
+
"GENERATORS",
|
|
71
|
+
"make_generator",
|
|
72
|
+
"LeverMetrics",
|
|
73
|
+
"RoundResult",
|
|
74
|
+
"LoopResult",
|
|
75
|
+
"SelectiveLabelsLoop",
|
|
76
|
+
]
|
|
77
|
+
|
|
78
|
+
#: Valid improve levers.
|
|
79
|
+
IMPROVE_MODES = ("reweight", "retrain", "both")
|
|
80
|
+
|
|
81
|
+
#: Valid cohort generators. ``"flat"`` is the original single-layer
|
|
82
|
+
#: ``SyntheticBorrowerGenerator`` (the default; existing artifacts and tests are
|
|
83
|
+
#: unchanged). ``"scm"`` is the fitted layered ``StructuralBorrowerGenerator``
|
|
84
|
+
#: with ``independent_selection_noise=True`` so severity keeps the same meaning
|
|
85
|
+
#: in both worlds (sev 0 == selection-at-random no propensity model can explain).
|
|
86
|
+
GENERATORS = ("flat", "scm")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def make_generator(
|
|
90
|
+
generator: str,
|
|
91
|
+
*,
|
|
92
|
+
severity: float,
|
|
93
|
+
seed: int,
|
|
94
|
+
n_applicants: int,
|
|
95
|
+
approval_rate: float,
|
|
96
|
+
):
|
|
97
|
+
"""Fresh single-shot cohort generator (shared by this loop and ``feedback``).
|
|
98
|
+
|
|
99
|
+
A fresh instance per cohort is required for determinism — both generator
|
|
100
|
+
classes consume their own ``Generator(PCG64(seed))`` on ``generate_cohort``,
|
|
101
|
+
so reusing an instance would silently shift streams. The SCM path always
|
|
102
|
+
sets ``independent_selection_noise=True`` (see ``GENERATORS`` note).
|
|
103
|
+
"""
|
|
104
|
+
if generator not in GENERATORS:
|
|
105
|
+
raise ValueError(f"generator must be one of {GENERATORS}; got {generator!r}")
|
|
106
|
+
if generator == "scm":
|
|
107
|
+
# independent_selection_noise=True: the SCM's default selection blend
|
|
108
|
+
# reuses the exogenous draw behind the OBSERVED prior_underwriter_score
|
|
109
|
+
# column, which would make low-severity selection explainable by the
|
|
110
|
+
# propensity model and invert the IPW lever's narrative. The dedicated
|
|
111
|
+
# frozen draw keeps severity semantics aligned with the flat world.
|
|
112
|
+
# The SCM matrix still exposes the prior-policy columns
|
|
113
|
+
# (prior_underwriter_score / prior_decision / prior_approved_amount) —
|
|
114
|
+
# intentional observed-policy features; with the independent draw they
|
|
115
|
+
# no longer encode this loop's selection mechanism.
|
|
116
|
+
return StructuralBorrowerGenerator(
|
|
117
|
+
n_applicants=n_applicants,
|
|
118
|
+
selection_severity=severity,
|
|
119
|
+
approval_rate=approval_rate,
|
|
120
|
+
seed=seed,
|
|
121
|
+
independent_selection_noise=True,
|
|
122
|
+
)
|
|
123
|
+
return SyntheticBorrowerGenerator(
|
|
124
|
+
n_applicants=n_applicants,
|
|
125
|
+
selection_severity=severity,
|
|
126
|
+
approval_rate=approval_rate,
|
|
127
|
+
seed=seed,
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@dataclass
|
|
132
|
+
class RoundResult:
|
|
133
|
+
"""Outcome of one generate -> measure -> improve round."""
|
|
134
|
+
|
|
135
|
+
iteration: int
|
|
136
|
+
selection_severity: float
|
|
137
|
+
base_rate: float
|
|
138
|
+
approval_rate: float
|
|
139
|
+
naive: LeverMetrics
|
|
140
|
+
control_metric: float # the declined ECE that drives loop control
|
|
141
|
+
passed: bool
|
|
142
|
+
improve_mode: str
|
|
143
|
+
reweight: LeverMetrics | None = None
|
|
144
|
+
retrain: LeverMetrics | None = None
|
|
145
|
+
#: Seed of the disjoint train cohort the retrain lever fitted on — makes the
|
|
146
|
+
#: train/measure separation auditable. ``None`` when not retraining.
|
|
147
|
+
retrain_train_seed: int | None = None
|
|
148
|
+
#: Exploration lever (``exploration_rate > 0`` only). Its ``declined_*``
|
|
149
|
+
#: metrics are computed on the *unexplored* declines — still a uniform random
|
|
150
|
+
#: subsample of the policy's declines, and never in the training set.
|
|
151
|
+
explore: LeverMetrics | None = None
|
|
152
|
+
#: Labels bought / defaults incurred by exploration this round. Both are
|
|
153
|
+
#: observable to a real lender (explored loans were funded).
|
|
154
|
+
n_explored: int = 0
|
|
155
|
+
explored_defaults: int = 0
|
|
156
|
+
#: Observable-only positivity diagnostics for the prior policy's selection
|
|
157
|
+
#: (see :mod:`cldd.diagnostics`). Computed every round; needs no labels.
|
|
158
|
+
diagnostics: PositivityDiagnostics | None = None
|
|
159
|
+
#: General name -> metrics map for every corrector applied this round. The
|
|
160
|
+
#: named ``naive``/``reweight``/``retrain``/``explore`` fields above are the
|
|
161
|
+
#: backward-compatible projection of this map for the four shipped levers.
|
|
162
|
+
corrections: dict[str, "LeverMetrics"] = field(default_factory=dict)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
@dataclass
|
|
166
|
+
class LoopResult:
|
|
167
|
+
"""Full history of a loop run plus the detector's operating frontier."""
|
|
168
|
+
|
|
169
|
+
rounds: list[RoundResult] = field(default_factory=list)
|
|
170
|
+
target_declined_ece: float = 0.0
|
|
171
|
+
#: Highest selection severity at which the corrected detector still cleared the
|
|
172
|
+
#: target calibration, or ``None`` if it never did.
|
|
173
|
+
frontier_severity: float | None = None
|
|
174
|
+
improve_mode: str = "both"
|
|
175
|
+
exploration_rate: float = 0.0
|
|
176
|
+
|
|
177
|
+
@property
|
|
178
|
+
def best_round(self) -> RoundResult | None:
|
|
179
|
+
return min(self.rounds, key=lambda r: r.control_metric) if self.rounds else None
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
class SelectiveLabelsLoop:
|
|
183
|
+
"""Drive the closed loop until the PD model reaches its operating frontier."""
|
|
184
|
+
|
|
185
|
+
def __init__(
|
|
186
|
+
self,
|
|
187
|
+
*,
|
|
188
|
+
target_declined_ece: float = config.TARGET_DECLINED_ECE,
|
|
189
|
+
start_severity: float = config.START_SEVERITY,
|
|
190
|
+
severity_step: float = config.SEVERITY_STEP,
|
|
191
|
+
max_severity: float = config.MAX_SEVERITY,
|
|
192
|
+
max_rounds: int = config.MAX_ROUNDS,
|
|
193
|
+
n_applicants: int = config.DEFAULT_N_APPLICANTS,
|
|
194
|
+
approval_rate: float = config.DEFAULT_APPROVAL_RATE,
|
|
195
|
+
seed: int = config.RANDOM_SEED,
|
|
196
|
+
improve_mode: str = "both",
|
|
197
|
+
train_seed_offset: int = config.TRAIN_SEED_OFFSET,
|
|
198
|
+
policy_threshold: float = config.POLICY_PD_THRESHOLD,
|
|
199
|
+
generator: str = "flat",
|
|
200
|
+
exploration_rate: float = 0.0,
|
|
201
|
+
correctors: list[Corrector] | None = None,
|
|
202
|
+
) -> None:
|
|
203
|
+
if improve_mode not in IMPROVE_MODES:
|
|
204
|
+
raise ValueError(f"improve_mode must be one of {IMPROVE_MODES}; got {improve_mode!r}")
|
|
205
|
+
if generator not in GENERATORS:
|
|
206
|
+
raise ValueError(f"generator must be one of {GENERATORS}; got {generator!r}")
|
|
207
|
+
if not 0.0 <= exploration_rate < 1.0:
|
|
208
|
+
raise ValueError(f"exploration_rate must be in [0, 1); got {exploration_rate!r}")
|
|
209
|
+
self.generator = generator
|
|
210
|
+
self.exploration_rate = exploration_rate
|
|
211
|
+
self.target_declined_ece = target_declined_ece
|
|
212
|
+
self.start_severity = start_severity
|
|
213
|
+
self.severity_step = severity_step
|
|
214
|
+
self.max_severity = max_severity
|
|
215
|
+
self.max_rounds = max_rounds
|
|
216
|
+
self.n_applicants = n_applicants
|
|
217
|
+
self.approval_rate = approval_rate
|
|
218
|
+
self.seed = seed
|
|
219
|
+
self.improve_mode = improve_mode
|
|
220
|
+
self.train_seed_offset = train_seed_offset
|
|
221
|
+
self.policy_threshold = policy_threshold
|
|
222
|
+
# When no explicit lever list is supplied, build the same set the legacy
|
|
223
|
+
# improve_mode/exploration_rate flags selected, in the same order:
|
|
224
|
+
# [naive, reweight?, retrain?, explore?]. Because each corrector uses an
|
|
225
|
+
# independent RNG seed, order does not affect numbers.
|
|
226
|
+
if correctors is None:
|
|
227
|
+
correctors = [NaiveCorrector()]
|
|
228
|
+
if improve_mode in ("reweight", "both"):
|
|
229
|
+
correctors.append(IPWReweightCorrector())
|
|
230
|
+
if improve_mode in ("retrain", "both"):
|
|
231
|
+
correctors.append(DisjointRetrainCorrector())
|
|
232
|
+
if exploration_rate > 0.0:
|
|
233
|
+
correctors.append(ExplorationCorrector())
|
|
234
|
+
# RoundResult projects the baseline detector as the non-optional `.naive`
|
|
235
|
+
# field, and run() reads corrections["naive"] each round, so a custom
|
|
236
|
+
# corrector list must include one named "naive". Fail fast with a clear
|
|
237
|
+
# message instead of a mid-run KeyError. (The default list always does.)
|
|
238
|
+
if not any(c.name == "naive" for c in correctors):
|
|
239
|
+
raise ValueError(
|
|
240
|
+
"correctors must include a corrector named 'naive' (e.g. "
|
|
241
|
+
"NaiveCorrector()); the loop projects it as RoundResult.naive. "
|
|
242
|
+
f"Got names: {[c.name for c in correctors]}"
|
|
243
|
+
)
|
|
244
|
+
self.correctors = correctors
|
|
245
|
+
|
|
246
|
+
# ------------------------------------------------------------------ #
|
|
247
|
+
# Cohort generation
|
|
248
|
+
# ------------------------------------------------------------------ #
|
|
249
|
+
|
|
250
|
+
def _new_generator(self, severity: float, seed: int):
|
|
251
|
+
"""Fresh single-shot generator for one cohort.
|
|
252
|
+
|
|
253
|
+
Both ``_generate`` and ``_generate_train`` MUST route through here so the
|
|
254
|
+
measure and retrain cohorts always come from the same generator class
|
|
255
|
+
(mixing them raises an sklearn n_features mismatch: 12 flat vs 28 SCM
|
|
256
|
+
columns). Delegates to :func:`make_generator` (shared with ``feedback``).
|
|
257
|
+
"""
|
|
258
|
+
return make_generator(
|
|
259
|
+
self.generator,
|
|
260
|
+
severity=severity,
|
|
261
|
+
seed=seed,
|
|
262
|
+
n_applicants=self.n_applicants,
|
|
263
|
+
approval_rate=self.approval_rate,
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
def _generate(self, severity: float, iteration: int) -> dict:
|
|
267
|
+
# a fresh cohort each round, still deterministic
|
|
268
|
+
return self._new_generator(severity, self.seed + iteration).generate_cohort()
|
|
269
|
+
|
|
270
|
+
def _generate_train(self, severity: float, iteration: int) -> tuple[dict, int]:
|
|
271
|
+
"""DISJOINT train cohort for the retrain lever (no-leakage rule).
|
|
272
|
+
|
|
273
|
+
Same severity, geometry, and generator class as the measure cohort but a
|
|
274
|
+
seed offset by ``train_seed_offset`` so neither its applicants nor its RNG
|
|
275
|
+
stream overlap the measure cohort (seed ``self.seed + iteration``).
|
|
276
|
+
"""
|
|
277
|
+
train_seed = self.seed + self.train_seed_offset + iteration
|
|
278
|
+
cohort = self._new_generator(severity, train_seed).generate_cohort()
|
|
279
|
+
return cohort, train_seed
|
|
280
|
+
|
|
281
|
+
# ------------------------------------------------------------------ #
|
|
282
|
+
# Run
|
|
283
|
+
# ------------------------------------------------------------------ #
|
|
284
|
+
|
|
285
|
+
def run(self) -> LoopResult:
|
|
286
|
+
"""Run the loop and return its history + frontier."""
|
|
287
|
+
result = LoopResult(
|
|
288
|
+
target_declined_ece=self.target_declined_ece,
|
|
289
|
+
improve_mode=self.improve_mode,
|
|
290
|
+
exploration_rate=self.exploration_rate,
|
|
291
|
+
)
|
|
292
|
+
severity = round(self.start_severity, 4)
|
|
293
|
+
|
|
294
|
+
for iteration in range(self.max_rounds):
|
|
295
|
+
cohort = self._generate(severity, iteration)
|
|
296
|
+
gt = cohort["ground_truth"]
|
|
297
|
+
|
|
298
|
+
# One context per round; the train-cohort closure preserves the
|
|
299
|
+
# no-leakage disjoint-train discipline (same severity/iteration).
|
|
300
|
+
ctx = CorrectorContext(
|
|
301
|
+
seed=self.seed,
|
|
302
|
+
policy_threshold=self.policy_threshold,
|
|
303
|
+
severity=severity,
|
|
304
|
+
iteration=iteration,
|
|
305
|
+
exploration_rate=self.exploration_rate,
|
|
306
|
+
make_train_cohort=lambda s=severity, i=iteration: self._generate_train(s, i),
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
corrections: dict[str, LeverMetrics] = {}
|
|
310
|
+
outcomes: list = [] # preserves corrector list order for tie-breaking
|
|
311
|
+
for corrector in self.correctors:
|
|
312
|
+
outcome = corrector.apply(cohort, ctx)
|
|
313
|
+
corrections[corrector.name] = outcome.metrics
|
|
314
|
+
outcomes.append((corrector, outcome))
|
|
315
|
+
|
|
316
|
+
# Backward-compatible named projection of the corrections map.
|
|
317
|
+
naive = corrections["naive"]
|
|
318
|
+
reweight = corrections.get("reweight")
|
|
319
|
+
retrain = corrections.get("retrain")
|
|
320
|
+
explore = corrections.get("explore")
|
|
321
|
+
train_seed = None
|
|
322
|
+
n_explored = explored_defaults = 0
|
|
323
|
+
for corrector, outcome in outcomes:
|
|
324
|
+
if corrector.name == "retrain":
|
|
325
|
+
train_seed = outcome.info["train_seed"]
|
|
326
|
+
elif corrector.name == "explore":
|
|
327
|
+
n_explored = outcome.info["n_explored"]
|
|
328
|
+
explored_defaults = outcome.info["explored_defaults"]
|
|
329
|
+
|
|
330
|
+
# Observable-only diagnostics on the prior policy's selection — what a
|
|
331
|
+
# real lender could monitor instead of the planted-truth ECE.
|
|
332
|
+
diag = positivity_diagnostics(
|
|
333
|
+
cohort["features"].to_numpy(dtype=float),
|
|
334
|
+
cohort["approved"],
|
|
335
|
+
random_state=self.seed,
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
# Control keys on the present corrector with the highest
|
|
339
|
+
# control_priority (ties -> first in the corrector list). This
|
|
340
|
+
# reproduces the legacy precedence explore>reweight>retrain>naive.
|
|
341
|
+
primary_corrector, primary_outcome = max(
|
|
342
|
+
enumerate(outcomes),
|
|
343
|
+
key=lambda io: (io[1][0].control_priority, -io[0]),
|
|
344
|
+
)[1]
|
|
345
|
+
control_metric = primary_outcome.metrics.declined_ece
|
|
346
|
+
passed = control_metric <= self.target_declined_ece
|
|
347
|
+
|
|
348
|
+
result.rounds.append(
|
|
349
|
+
RoundResult(
|
|
350
|
+
iteration=iteration,
|
|
351
|
+
selection_severity=severity,
|
|
352
|
+
base_rate=gt["base_rate"],
|
|
353
|
+
approval_rate=gt["approval_rate"],
|
|
354
|
+
naive=naive,
|
|
355
|
+
reweight=reweight,
|
|
356
|
+
retrain=retrain,
|
|
357
|
+
retrain_train_seed=train_seed,
|
|
358
|
+
explore=explore,
|
|
359
|
+
n_explored=n_explored,
|
|
360
|
+
explored_defaults=explored_defaults,
|
|
361
|
+
diagnostics=diag,
|
|
362
|
+
control_metric=control_metric,
|
|
363
|
+
passed=passed,
|
|
364
|
+
improve_mode=self.improve_mode,
|
|
365
|
+
corrections=corrections,
|
|
366
|
+
)
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
if not passed:
|
|
370
|
+
# Frontier reached: correction can no longer hold calibration here.
|
|
371
|
+
break
|
|
372
|
+
|
|
373
|
+
result.frontier_severity = severity
|
|
374
|
+
next_severity = round(severity + self.severity_step, 4)
|
|
375
|
+
if next_severity > self.max_severity:
|
|
376
|
+
break # probed as hard as configured; detector still holding
|
|
377
|
+
severity = next_severity
|
|
378
|
+
|
|
379
|
+
return result
|
cldd/model_pd.py
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"""Minimal but real calibrated probability-of-default model + IPW weights.
|
|
2
|
+
|
|
3
|
+
This is the detector the closed loop drives — the analog of the cross-omics
|
|
4
|
+
detector in ``upstream-label-correction``. Two things matter here:
|
|
5
|
+
|
|
6
|
+
1. **Calibration**, not just ranking: the closed loop scores calibration error
|
|
7
|
+
on the declined subpopulation, so the model must emit honest probabilities.
|
|
8
|
+
We fit a gradient-boosted classifier, then isotonic-calibrate it on a
|
|
9
|
+
held-out slice of the training data.
|
|
10
|
+
2. **Sample weights** flow end to end, so the loop's inverse-propensity
|
|
11
|
+
(IPW) selection-bias lever can reweight the approved training rows.
|
|
12
|
+
|
|
13
|
+
scikit-learn only — ``HistGradientBoostingClassifier`` handles the structural
|
|
14
|
+
NaNs natively, so no imputation is needed. Everything is deterministic per seed.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import numpy as np
|
|
20
|
+
from sklearn.base import BaseEstimator, ClassifierMixin
|
|
21
|
+
from sklearn.ensemble import HistGradientBoostingClassifier
|
|
22
|
+
from sklearn.isotonic import IsotonicRegression
|
|
23
|
+
from sklearn.model_selection import train_test_split
|
|
24
|
+
from sklearn.utils.multiclass import type_of_target
|
|
25
|
+
from sklearn.utils.validation import _check_sample_weight, check_is_fitted, validate_data
|
|
26
|
+
|
|
27
|
+
from . import config
|
|
28
|
+
|
|
29
|
+
#: Below this many samples in the minority class, skip the held-out isotonic step
|
|
30
|
+
#: (not enough positives to calibrate honestly) and return raw GBT probabilities.
|
|
31
|
+
_MIN_CLASS_FOR_CALIBRATION = 8
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class CalibratedPDModel:
|
|
35
|
+
"""A fitted GBT classifier plus an optional isotonic calibrator."""
|
|
36
|
+
|
|
37
|
+
def __init__(self, classifier: HistGradientBoostingClassifier, calibrator: IsotonicRegression | None):
|
|
38
|
+
self.classifier = classifier
|
|
39
|
+
self.calibrator = calibrator
|
|
40
|
+
|
|
41
|
+
def predict_pd(self, X: np.ndarray) -> np.ndarray:
|
|
42
|
+
raw = self.classifier.predict_proba(np.asarray(X, dtype=float))[:, 1]
|
|
43
|
+
if self.calibrator is not None:
|
|
44
|
+
raw = self.calibrator.predict(raw)
|
|
45
|
+
return np.clip(raw, 0.0, 1.0)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class CalibratedPDClassifier(ClassifierMixin, BaseEstimator):
|
|
49
|
+
"""scikit-learn-compatible face of the calibrated PD detector.
|
|
50
|
+
|
|
51
|
+
A thin ``BaseEstimator``/``ClassifierMixin`` wrapper around
|
|
52
|
+
:func:`train_pd_model` — ``fit`` delegates to it verbatim (same seed
|
|
53
|
+
conventions, same sample-weight forwarding, same calibration-skip rule), so
|
|
54
|
+
the probabilities in ``predict_proba(X)[:, 1]`` are byte-identical to
|
|
55
|
+
``train_pd_model(X, y, ...).predict_pd(X)``. Use this class from sklearn
|
|
56
|
+
tooling (``clone``, ``Pipeline``, ``cross_validate``); use the functional
|
|
57
|
+
API from the closed loop.
|
|
58
|
+
|
|
59
|
+
Binary problems only: ``fit`` raises ``ValueError`` unless ``y`` has exactly
|
|
60
|
+
two classes (the PD domain is a default/repaid indicator). One semantic
|
|
61
|
+
caveat: exact sample-weight *equivalence* (weight k == repeat k times) is
|
|
62
|
+
not guaranteed by design — the calibration split is drawn on row indices
|
|
63
|
+
and ``HistGradientBoostingClassifier`` bins features — although the
|
|
64
|
+
``check_estimator`` battery (which every generated check passes on the
|
|
65
|
+
tested sklearn versions; see ``tests/test_sklearn_compat.py``) does not
|
|
66
|
+
currently detect a violation.
|
|
67
|
+
|
|
68
|
+
Parameters
|
|
69
|
+
----------
|
|
70
|
+
random_state : int or None
|
|
71
|
+
Seed forwarded to :func:`train_pd_model`; ``None`` falls back to
|
|
72
|
+
``config.RANDOM_SEED``, so fitting is deterministic either way.
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
def __init__(self, random_state: int | None = None):
|
|
76
|
+
self.random_state = random_state
|
|
77
|
+
|
|
78
|
+
def fit(self, X, y, sample_weight=None) -> "CalibratedPDClassifier":
|
|
79
|
+
"""Fit the calibrated PD model; returns ``self``."""
|
|
80
|
+
X, y = validate_data(self, X, y, dtype=float, ensure_all_finite="allow-nan")
|
|
81
|
+
y_type = type_of_target(y, input_name="y", raise_unknown=True)
|
|
82
|
+
if y_type != "binary": # sklearn's prescribed rejection for multi_class=False
|
|
83
|
+
raise ValueError(
|
|
84
|
+
"Only binary classification is supported. The type of the target "
|
|
85
|
+
f"is {y_type}."
|
|
86
|
+
)
|
|
87
|
+
self.classes_, y_encoded = np.unique(y, return_inverse=True)
|
|
88
|
+
if len(self.classes_) != 2:
|
|
89
|
+
raise ValueError(
|
|
90
|
+
f"CalibratedPDClassifier requires exactly 2 classes; "
|
|
91
|
+
f"got {len(self.classes_)} class(es): {self.classes_!r}"
|
|
92
|
+
)
|
|
93
|
+
if sample_weight is not None:
|
|
94
|
+
sample_weight = _check_sample_weight(sample_weight, X, dtype=np.float64)
|
|
95
|
+
self.model_ = train_pd_model(
|
|
96
|
+
X, y_encoded, sample_weight=sample_weight, random_state=self.random_state
|
|
97
|
+
)
|
|
98
|
+
return self
|
|
99
|
+
|
|
100
|
+
def predict_proba(self, X) -> np.ndarray:
|
|
101
|
+
"""Calibrated class probabilities, shape ``(n_samples, 2)``."""
|
|
102
|
+
check_is_fitted(self)
|
|
103
|
+
X = validate_data(self, X, dtype=float, ensure_all_finite="allow-nan", reset=False)
|
|
104
|
+
pd_hat = self.model_.predict_pd(X)
|
|
105
|
+
return np.column_stack((1.0 - pd_hat, pd_hat))
|
|
106
|
+
|
|
107
|
+
def predict(self, X) -> np.ndarray:
|
|
108
|
+
"""Most-probable class label per row (in the labels ``fit`` was given)."""
|
|
109
|
+
proba = self.predict_proba(X) # runs the fitted check before classes_ is touched
|
|
110
|
+
return self.classes_[np.argmax(proba, axis=1)]
|
|
111
|
+
|
|
112
|
+
def __sklearn_tags__(self):
|
|
113
|
+
tags = super().__sklearn_tags__()
|
|
114
|
+
tags.classifier_tags.multi_class = False # PD is a binary domain
|
|
115
|
+
tags.input_tags.allow_nan = True # HistGradientBoosting handles structural NaNs
|
|
116
|
+
return tags
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _new_classifier(random_state: int) -> HistGradientBoostingClassifier:
|
|
120
|
+
return HistGradientBoostingClassifier(
|
|
121
|
+
random_state=random_state,
|
|
122
|
+
max_depth=3,
|
|
123
|
+
max_iter=200,
|
|
124
|
+
learning_rate=0.06,
|
|
125
|
+
l2_regularization=1.0,
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def train_pd_model(
|
|
130
|
+
X,
|
|
131
|
+
y,
|
|
132
|
+
sample_weight=None,
|
|
133
|
+
random_state: int | None = None,
|
|
134
|
+
) -> CalibratedPDModel:
|
|
135
|
+
"""Fit the PD estimator and return a calibrated model.
|
|
136
|
+
|
|
137
|
+
``sample_weight`` (e.g. IPW weights) is forwarded to both the classifier and
|
|
138
|
+
the isotonic calibrator so the selection-bias correction is honored throughout.
|
|
139
|
+
"""
|
|
140
|
+
X = np.asarray(X, dtype=float)
|
|
141
|
+
y = np.asarray(y, dtype=int)
|
|
142
|
+
rs = config.RANDOM_SEED if random_state is None else random_state
|
|
143
|
+
sw = None if sample_weight is None else np.asarray(sample_weight, dtype=float)
|
|
144
|
+
|
|
145
|
+
clf = _new_classifier(rs)
|
|
146
|
+
counts = np.bincount(y, minlength=2)
|
|
147
|
+
if len(np.unique(y)) < 2 or counts.min() < _MIN_CLASS_FOR_CALIBRATION:
|
|
148
|
+
# Too few positives (or one class) to carve a calibration split — fit on
|
|
149
|
+
# everything and skip isotonic rather than fail.
|
|
150
|
+
clf.fit(X, y, sample_weight=sw)
|
|
151
|
+
return CalibratedPDModel(clf, None)
|
|
152
|
+
|
|
153
|
+
idx = np.arange(len(y))
|
|
154
|
+
fit_idx, cal_idx = train_test_split(idx, test_size=0.3, random_state=rs, stratify=y)
|
|
155
|
+
clf.fit(X[fit_idx], y[fit_idx], sample_weight=None if sw is None else sw[fit_idx])
|
|
156
|
+
|
|
157
|
+
raw_cal = clf.predict_proba(X[cal_idx])[:, 1]
|
|
158
|
+
iso = IsotonicRegression(out_of_bounds="clip", y_min=0.0, y_max=1.0)
|
|
159
|
+
iso.fit(raw_cal, y[cal_idx], sample_weight=None if sw is None else sw[cal_idx])
|
|
160
|
+
return CalibratedPDModel(clf, iso)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def predict_pd(model: CalibratedPDModel, X) -> np.ndarray:
|
|
164
|
+
"""Calibrated PD point estimates in [0, 1], one per row."""
|
|
165
|
+
return model.predict_pd(np.asarray(X, dtype=float))
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def selection_adjusted_weights(
|
|
169
|
+
X,
|
|
170
|
+
approved,
|
|
171
|
+
clip: tuple[float, float] = (0.05, 0.95),
|
|
172
|
+
random_state: int | None = None,
|
|
173
|
+
) -> np.ndarray:
|
|
174
|
+
"""Inverse-propensity weights that correct prior-approval selection bias.
|
|
175
|
+
|
|
176
|
+
Fits a propensity model ``P(approved | features)`` and returns ``1/propensity``
|
|
177
|
+
for every row. The loop applies the approved-row slice as training weights so
|
|
178
|
+
the funded sample is reweighted back toward the full applicant population.
|
|
179
|
+
|
|
180
|
+
Note the assumption this rides on (the heart of the Deliverable D causal
|
|
181
|
+
discussion): IPW only corrects selection that is explained by the *observed*
|
|
182
|
+
features. The synthetic generator deliberately routes part of the selection
|
|
183
|
+
through an unobserved confounder, so this correction degrades as selection
|
|
184
|
+
severity rises — which is exactly the frontier the loop measures.
|
|
185
|
+
"""
|
|
186
|
+
X = np.asarray(X, dtype=float)
|
|
187
|
+
approved = np.asarray(approved).astype(int)
|
|
188
|
+
rs = config.RANDOM_SEED if random_state is None else random_state
|
|
189
|
+
|
|
190
|
+
propensity_model = _new_classifier(rs)
|
|
191
|
+
propensity_model.fit(X, approved)
|
|
192
|
+
propensity = propensity_model.predict_proba(X)[:, 1]
|
|
193
|
+
propensity = np.clip(propensity, clip[0], clip[1])
|
|
194
|
+
return 1.0 / propensity
|
cldd/py.typed
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# PEP 561 marker: cldd ships inline type information.
|