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/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.