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