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.
@@ -0,0 +1,264 @@
1
+ """Reject-inference correction levers, graded against planted ground truth.
2
+
3
+ These are **Correctors-under-grade**, not a practitioner-facing reject-inference
4
+ API. There is deliberately no ``run_reject_inference_on(df)`` helper: the whole
5
+ point of the harness is that every method is scored against the *planted* default
6
+ labels on the applicants a prior policy declined, which real data does not have.
7
+ The moment a method is exposed for users' real data the oracle is gone, so the
8
+ public surface here is only the closed loop's ``Corrector`` contract.
9
+
10
+ **Integrity invariant (the make-or-break property).** A reject-inference corrector
11
+ may use the declined applicants' *features* but never their planted
12
+ ``true_default`` at fit time — exactly the no-leakage discipline the naive lever
13
+ already follows (it fits approved rows only). This is enforced *structurally*: a
14
+ subclass's :meth:`RejectInferenceCorrector._augment` is handed the feature matrix,
15
+ the **approved** rows' (observable) labels, and a KGB model — it is never given
16
+ the declined labels at all. ``test_reject_inference.py`` additionally asserts the
17
+ fitted model is invariant to corrupting the declined labels.
18
+
19
+ **What the numbers certify.** Declined-cohort calibration is measured on a
20
+ **held-out fold** of the declines (``config.RI_EVAL_FRACTION``): the method may
21
+ pseudo-label one fold of the declines for training, and is graded on the *other*
22
+ fold it never saw. So a number here certifies "calibration recovered on held-out
23
+ declined applicants from this cohort", not generalization of the pseudo-labeling
24
+ rule to a fresh cohort (a different claim — see :func:`split_declines`).
25
+
26
+ The four shipped methods all route their constructed training set through the
27
+ same ``model_pd.train_pd_model`` the naive lever uses, so a frontier table over
28
+ {naive, IPW, these four} compares training-set construction with the downstream
29
+ PD model held fixed.
30
+
31
+ *Conceptual anchor (a pointer, not a treatise):* the reweighting family is the
32
+ importance-sampling dual of rejection sampling against the selection policy, and
33
+ both fail at the positivity boundary — so the operating frontier is where the
34
+ inverse map stops existing.
35
+ """
36
+
37
+ from __future__ import annotations
38
+
39
+ from abc import abstractmethod
40
+
41
+ import numpy as np
42
+
43
+ from . import config, eval_default, model_pd
44
+ from .correctors import CorrectionOutcome, Corrector, CorrectorContext, LeverMetrics
45
+
46
+ #: Acceptance-rate / propensity clip shared with the IPW lever's spirit — keeps
47
+ #: band reweighting from exploding on a near-empty band.
48
+ _RATE_CLIP = (0.05, 1.0)
49
+
50
+
51
+ def split_declines(
52
+ approved,
53
+ *,
54
+ seed: int,
55
+ iteration: int,
56
+ eval_fraction: float = config.RI_EVAL_FRACTION,
57
+ ) -> tuple[np.ndarray, np.ndarray]:
58
+ """Split the declined rows into a pseudo-label fold and a held-out grading fold.
59
+
60
+ Deterministic given ``(seed, iteration)`` via a dedicated RNG stream
61
+ (``config.RI_SPLIT_STREAM``), so every RI corrector and the frontier driver
62
+ score on identical eval rows — the comparison stays apples-to-apples. Returns
63
+ ``(train_idx, eval_idx)`` as sorted arrays of row indices into the cohort.
64
+ """
65
+ declined_idx = np.flatnonzero(~np.asarray(approved, dtype=bool))
66
+ rng = np.random.default_rng([seed, iteration, config.RI_SPLIT_STREAM])
67
+ perm = rng.permutation(declined_idx)
68
+ n_eval = int(round(len(declined_idx) * eval_fraction))
69
+ eval_idx = np.sort(perm[:n_eval])
70
+ train_idx = np.sort(perm[n_eval:])
71
+ return train_idx, eval_idx
72
+
73
+
74
+ def grade_on_declined_fold(model, cohort: dict, eval_idx: np.ndarray, threshold: float) -> LeverMetrics:
75
+ """Score ``model`` so the ``declined`` subgroup is exactly ``eval_idx``.
76
+
77
+ ``score_pd_detection``'s ``declined`` subgroup is ``~funded``; we mark every
78
+ row funded except the held-out eval fold, so declined-cohort metrics are
79
+ computed on the held-out declines against their planted truth (grading only).
80
+ """
81
+ approved = np.asarray(cohort["approved"], dtype=bool)
82
+ funded = np.ones(approved.shape[0], dtype=bool)
83
+ funded[eval_idx] = False
84
+ scored = eval_default.score_pd_detection(model, cohort, threshold, funded=funded)
85
+ return LeverMetrics.from_scored(scored)
86
+
87
+
88
+ def _score_bands(p: np.ndarray, n_bands: int) -> np.ndarray:
89
+ """Assign each score to one of ``n_bands`` quantile bands (0..n_bands-1)."""
90
+ edges = np.quantile(p, np.linspace(0.0, 1.0, n_bands + 1))
91
+ return np.clip(np.digitize(p, edges[1:-1]), 0, n_bands - 1)
92
+
93
+
94
+ class RejectInferenceCorrector(Corrector):
95
+ """Base for reject-inference levers.
96
+
97
+ Subclasses implement :meth:`_augment`, which — given the feature matrix, the
98
+ **approved** rows' labels, a KGB model fit on the approved rows, the approved
99
+ mask, and the pseudo-label fold of declined indices — returns the extra
100
+ training rows (declined features with *pseudo*-labels) and/or per-approved-row
101
+ weights. Crucially, ``_augment`` is never handed the declined ``true_default``,
102
+ so the integrity invariant holds by construction.
103
+
104
+ The base owns: the declined train/eval split, assembling the training set,
105
+ fitting via ``model_pd.train_pd_model`` (the same estimator the naive lever
106
+ uses), and grading on the held-out declined fold.
107
+ """
108
+
109
+ #: Above the built-in levers (naive=0, retrain=1, reweight=2, explore=3) so an
110
+ #: RI lever drives loop control if dropped into a ``SelectiveLabelsLoop`` —
111
+ #: though these levers are designed to be graded by ``run_reject_inference.py``.
112
+ control_priority = 4
113
+
114
+ @abstractmethod
115
+ def _augment(
116
+ self,
117
+ X: np.ndarray,
118
+ y_approved: np.ndarray,
119
+ kgb: model_pd.CalibratedPDModel,
120
+ approved: np.ndarray,
121
+ train_idx: np.ndarray,
122
+ ctx: CorrectorContext,
123
+ ) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray | None]:
124
+ """Return ``(extra_X, extra_y, extra_w, approved_w)``.
125
+
126
+ ``extra_*`` are declined rows to ADD to training with *pseudo*-labels
127
+ (may be empty arrays). ``approved_w`` is per-approved-row weights, or
128
+ ``None`` for uniform. MUST NOT read the declined planted labels.
129
+ """
130
+ ...
131
+
132
+ def _fit(self, cohort: dict, ctx: CorrectorContext) -> model_pd.CalibratedPDModel:
133
+ X = cohort["features"].to_numpy(dtype=float)
134
+ approved = np.asarray(cohort["approved"], dtype=bool)
135
+ y = np.asarray(cohort["true_default"], dtype=int)
136
+ y_approved = y[approved]
137
+ train_idx, _ = split_declines(approved, seed=ctx.seed, iteration=ctx.iteration)
138
+
139
+ # KGB ("known good/bad") model: the naive detector, fit on approved rows
140
+ # only with their observable labels. Reused by every method to score declines.
141
+ kgb = eval_default.fit_observed_model(cohort, random_state=ctx.seed)
142
+
143
+ extra_X, extra_y, extra_w, approved_w = self._augment(
144
+ X, y_approved, kgb, approved, train_idx, ctx
145
+ )
146
+
147
+ X_app = X[approved]
148
+ w_app = np.ones(int(approved.sum())) if approved_w is None else np.asarray(approved_w, dtype=float)
149
+ extra_X = np.asarray(extra_X, dtype=float)
150
+ if extra_X.size:
151
+ X_train = np.vstack([X_app, extra_X])
152
+ y_train = np.concatenate([y_approved, np.asarray(extra_y, dtype=int)])
153
+ w_train = np.concatenate([w_app, np.asarray(extra_w, dtype=float)])
154
+ else:
155
+ X_train, y_train, w_train = X_app, y_approved, w_app
156
+
157
+ return model_pd.train_pd_model(X_train, y_train, sample_weight=w_train, random_state=ctx.seed)
158
+
159
+ def apply(self, cohort: dict, ctx: CorrectorContext) -> CorrectionOutcome:
160
+ approved = np.asarray(cohort["approved"], dtype=bool)
161
+ train_idx, eval_idx = split_declines(approved, seed=ctx.seed, iteration=ctx.iteration)
162
+ model = self._fit(cohort, ctx)
163
+ metrics = grade_on_declined_fold(model, cohort, eval_idx, ctx.policy_threshold)
164
+ return CorrectionOutcome(
165
+ metrics=metrics,
166
+ info={"n_train_declines": int(train_idx.size), "n_eval_declines": int(eval_idx.size)},
167
+ )
168
+
169
+
170
+ class ReclassificationCorrector(RejectInferenceCorrector):
171
+ """Hard-cutoff reclassification: pseudo-label the declined fold by thresholding
172
+ the KGB score at ``policy_threshold``, then add those rows to training."""
173
+
174
+ name = "reclassification"
175
+
176
+ def _augment(self, X, y_approved, kgb, approved, train_idx, ctx):
177
+ if train_idx.size == 0:
178
+ empty = np.empty((0, X.shape[1]))
179
+ return empty, np.empty(0, dtype=int), np.empty(0), None
180
+ p = model_pd.predict_pd(kgb, X[train_idx])
181
+ pseudo = (p >= ctx.policy_threshold).astype(int)
182
+ weights = np.ones(train_idx.size)
183
+ return X[train_idx], pseudo, weights, None
184
+
185
+
186
+ class AugmentationCorrector(RejectInferenceCorrector):
187
+ """Score-band augmentation (a.k.a. reweighting): reweight ACCEPTED rows by the
188
+ inverse of their KGB-score band's empirical acceptance rate, so the weighted
189
+ accepted sample represents the full through-the-door population.
190
+
191
+ Distinct from :class:`cldd.correctors.IPWReweightCorrector`, which fits a
192
+ continuous propensity *classifier* ``P(approved | features)``. Augmentation
193
+ instead bins by the KGB risk *score* and uses each band's empirical
194
+ accepted/total ratio (the classic credit-scoring construction). It adds no
195
+ declined rows to training; it uses declined *features* only to count band totals.
196
+ """
197
+
198
+ name = "augmentation"
199
+
200
+ def _augment(self, X, y_approved, kgb, approved, train_idx, ctx):
201
+ p = model_pd.predict_pd(kgb, X)
202
+ bands = _score_bands(p, config.RI_SCORE_BANDS)
203
+ approved_w = np.ones(int(approved.sum()))
204
+ band_of_approved = bands[approved]
205
+ for b in range(config.RI_SCORE_BANDS):
206
+ total = int((bands == b).sum())
207
+ if total == 0:
208
+ continue
209
+ acc_rate = float((approved & (bands == b)).sum()) / total
210
+ acc_rate = float(np.clip(acc_rate, _RATE_CLIP[0], _RATE_CLIP[1]))
211
+ approved_w[band_of_approved == b] = 1.0 / acc_rate
212
+ empty = np.empty((0, X.shape[1]))
213
+ return empty, np.empty(0, dtype=int), np.empty(0), approved_w
214
+
215
+
216
+ class FuzzyAugmentationCorrector(RejectInferenceCorrector):
217
+ """Fuzzy augmentation: each declined-fold applicant enters training TWICE — as
218
+ a good (label 0, weight ``1 - p``) and a bad (label 1, weight ``p``) record,
219
+ where ``p`` is the KGB-predicted PD. No hard label is ever assigned."""
220
+
221
+ name = "fuzzy_augmentation"
222
+
223
+ def _augment(self, X, y_approved, kgb, approved, train_idx, ctx):
224
+ if train_idx.size == 0:
225
+ empty = np.empty((0, X.shape[1]))
226
+ return empty, np.empty(0, dtype=int), np.empty(0), None
227
+ Xd = X[train_idx]
228
+ p = model_pd.predict_pd(kgb, Xd)
229
+ extra_X = np.vstack([Xd, Xd])
230
+ extra_y = np.concatenate([np.ones(train_idx.size, dtype=int), np.zeros(train_idx.size, dtype=int)])
231
+ extra_w = np.concatenate([p, 1.0 - p])
232
+ return extra_X, extra_y, extra_w, None
233
+
234
+
235
+ class ParcellingCorrector(RejectInferenceCorrector):
236
+ """Parcelling: bin the declined fold by KGB score; within each band assign a
237
+ bad label with probability ``min(accepted_band_bad_rate * RI_PARCEL_FACTOR, 1)``
238
+ (the uplift encodes that declines are riskier than look-alike accepts), drawn
239
+ from a dedicated seeded RNG stream so the labels are deterministic."""
240
+
241
+ name = "parcelling"
242
+
243
+ def _augment(self, X, y_approved, kgb, approved, train_idx, ctx):
244
+ if train_idx.size == 0:
245
+ empty = np.empty((0, X.shape[1]))
246
+ return empty, np.empty(0, dtype=int), np.empty(0), None
247
+ p_all = model_pd.predict_pd(kgb, X)
248
+ bands = _score_bands(p_all, config.RI_SCORE_BANDS)
249
+ overall_bad = float(np.mean(y_approved)) if y_approved.size else 0.0
250
+
251
+ band_target = np.full(config.RI_SCORE_BANDS, overall_bad)
252
+ band_of_approved = bands[approved]
253
+ for b in range(config.RI_SCORE_BANDS):
254
+ in_band = band_of_approved == b
255
+ if in_band.any():
256
+ band_target[b] = float(np.mean(y_approved[in_band]))
257
+ band_target = np.clip(band_target * config.RI_PARCEL_FACTOR, 0.0, 1.0)
258
+
259
+ bands_train = bands[train_idx]
260
+ rng = np.random.default_rng([ctx.seed, ctx.iteration, config.RI_PARCEL_STREAM])
261
+ draws = rng.random(train_idx.size)
262
+ pseudo = (draws < band_target[bands_train]).astype(int)
263
+ weights = np.ones(train_idx.size)
264
+ return X[train_idx], pseudo, weights, None