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 ADDED
@@ -0,0 +1,96 @@
1
+ """Closed-loop default-rate detection — a CLUE-style harness for selective labels.
2
+
3
+ generate (``synthetic``) -> measure (``eval_default``) -> improve/frontier
4
+ (``loop``). See README.md for the CLUE mapping and how to run.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from importlib.metadata import PackageNotFoundError, version as _pkg_version
10
+
11
+ try: # pyproject `version` is the single source of truth (read from installed metadata)
12
+ __version__ = _pkg_version("closed-loop-default-detection")
13
+ except PackageNotFoundError: # running from a source tree with no install
14
+ __version__ = "0.1.0"
15
+
16
+ from .correctors import (
17
+ CorrectionOutcome,
18
+ Corrector,
19
+ CorrectorContext,
20
+ DisjointRetrainCorrector,
21
+ ExplorationCorrector,
22
+ IPWReweightCorrector,
23
+ NaiveCorrector,
24
+ )
25
+ from .counterfactual import (
26
+ CounterfactualResult,
27
+ GComputationEstimator,
28
+ generate_queries,
29
+ run_counterfactual_eval,
30
+ )
31
+ from .diagnostics import PositivityDiagnostics, positivity_diagnostics
32
+ from .reject_inference import (
33
+ AugmentationCorrector,
34
+ FuzzyAugmentationCorrector,
35
+ ParcellingCorrector,
36
+ ReclassificationCorrector,
37
+ RejectInferenceCorrector,
38
+ )
39
+ from .eval_default import PdDetectionResult, fit_observed_model, score_pd_detection
40
+ from .model_pd import CalibratedPDClassifier, CalibratedPDModel
41
+ from .feedback import FeedbackLoop, FeedbackResult, GenerationResult
42
+ from .loop import LeverMetrics, LoopResult, RoundResult, SelectiveLabelsLoop
43
+ from .scm import (
44
+ BANK_FEED_COLUMNS,
45
+ FEATURE_COLUMNS,
46
+ INTERVENABLE_FEATURES,
47
+ InterventionResult,
48
+ SCMState,
49
+ StructuralBorrowerGenerator,
50
+ dag_children,
51
+ dag_parents,
52
+ )
53
+ from .synthetic import SyntheticBorrowerGenerator
54
+
55
+ __all__ = [
56
+ "__version__",
57
+ "SyntheticBorrowerGenerator",
58
+ "StructuralBorrowerGenerator",
59
+ "SCMState",
60
+ "InterventionResult",
61
+ "INTERVENABLE_FEATURES",
62
+ "FEATURE_COLUMNS",
63
+ "BANK_FEED_COLUMNS",
64
+ "dag_children",
65
+ "dag_parents",
66
+ "SelectiveLabelsLoop",
67
+ "LoopResult",
68
+ "RoundResult",
69
+ "LeverMetrics",
70
+ "Corrector",
71
+ "CorrectorContext",
72
+ "CorrectionOutcome",
73
+ "NaiveCorrector",
74
+ "IPWReweightCorrector",
75
+ "DisjointRetrainCorrector",
76
+ "ExplorationCorrector",
77
+ "RejectInferenceCorrector",
78
+ "ReclassificationCorrector",
79
+ "AugmentationCorrector",
80
+ "FuzzyAugmentationCorrector",
81
+ "ParcellingCorrector",
82
+ "FeedbackLoop",
83
+ "FeedbackResult",
84
+ "GenerationResult",
85
+ "PositivityDiagnostics",
86
+ "positivity_diagnostics",
87
+ "PdDetectionResult",
88
+ "fit_observed_model",
89
+ "score_pd_detection",
90
+ "CalibratedPDModel",
91
+ "CalibratedPDClassifier",
92
+ "CounterfactualResult",
93
+ "GComputationEstimator",
94
+ "run_counterfactual_eval",
95
+ "generate_queries",
96
+ ]
cldd/config.py ADDED
@@ -0,0 +1,94 @@
1
+ """Central configuration: paths, seeds, loan economics, and the frontier grid.
2
+
3
+ Everything here is either a *fact* (loan terms, the SMB challenge's ~17% base
4
+ default rate) or a single-source-of-truth knob for the closed loop. Per-model
5
+ hyperparameters live in the individual modules so this file stays scannable.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from pathlib import Path
11
+
12
+ # --------------------------------------------------------------------------- #
13
+ # Paths
14
+ # --------------------------------------------------------------------------- #
15
+
16
+ ROOT = Path(__file__).resolve().parents[2] # repo root
17
+ ARTIFACTS_DIR = ROOT / "artifacts" # gitignored; frontier CSV + plots
18
+
19
+ # --------------------------------------------------------------------------- #
20
+ # Reproducibility
21
+ # --------------------------------------------------------------------------- #
22
+
23
+ RANDOM_SEED = 42
24
+
25
+ # Disjoint train-cohort offset for the retrain lever. Large enough that train
26
+ # seeds (RANDOM_SEED + TRAIN_SEED_OFFSET + iteration) never collide with measure
27
+ # seeds (RANDOM_SEED + iteration). This is the no-leakage discipline ported from
28
+ # upstream-label-correction/clue/loop.py.
29
+ TRAIN_SEED_OFFSET = 1000
30
+
31
+ # --------------------------------------------------------------------------- #
32
+ # Synthetic SMB loan economics + portfolio shape (mirrors the challenge brief)
33
+ # --------------------------------------------------------------------------- #
34
+
35
+ TERM_DAYS = 60 # daily ACH draws over a 60-day term
36
+ APR = 0.35 # annualized
37
+ ORIGINATION_FEE_RATE = 0.03 # 3% of amount, collected up front
38
+
39
+ TARGET_BASE_DEFAULT_RATE = 0.17 # the challenge's labeled pool ran ~17.4%
40
+ DEFAULT_APPROVAL_RATE = 0.60 # fraction of applicants the prior policy funds
41
+
42
+ # Decision threshold on PD used only for the *detection F1* diagnostic
43
+ # (approve/decline economics are out of scope for this harness).
44
+ POLICY_PD_THRESHOLD = 0.5
45
+
46
+ # --------------------------------------------------------------------------- #
47
+ # Closed-loop frontier search over selection severity
48
+ # --------------------------------------------------------------------------- #
49
+
50
+ START_SEVERITY = 0.0 # severity 0 == approval independent of true risk (MAR)
51
+ SEVERITY_STEP = 0.2
52
+ MAX_SEVERITY = 1.0 # severity 1 == approval tracks full latent risk
53
+ MAX_ROUNDS = 8
54
+
55
+ # A round "passes" when the corrected declined-subpopulation calibration error
56
+ # (ECE) stays at or below this. Lower is better; the frontier is the highest
57
+ # severity still passing.
58
+ TARGET_DECLINED_ECE = 0.10
59
+
60
+ DEFAULT_N_APPLICANTS = 4000
61
+
62
+ # --------------------------------------------------------------------------- #
63
+ # Observable positivity-diagnostic thresholds (cldd.diagnostics)
64
+ # --------------------------------------------------------------------------- #
65
+ # Calibrated on this harness's severity grids (flat + SCM, seeds {7, 42, 2026},
66
+ # n=4000): each component individually separates every pass-severity cell
67
+ # (<= 0.4) from every fail-severity cell (>= 0.6) in all 6 grid runs —
68
+ # pass-side worst cases AUC 0.831 / ESS 0.915 / floor 0.0025 vs fail-side worst
69
+ # cases 0.877 / 0.844 / 0.020. The flag detects the positivity-breakdown
70
+ # REGIME, not per-cohort ECE (one flat seed missed the 0.10 ECE target at
71
+ # severity 0.4 with healthy diagnostics). A *proposal* for real-data
72
+ # monitoring, validated only in the two synthetic worlds.
73
+ DIAG_PROPENSITY_AUC_MAX = 0.85
74
+ DIAG_ESS_RATIO_MIN = 0.875
75
+ DIAG_UNFUNDED_BELOW_FLOOR_MAX = 0.01
76
+
77
+ # Exploration lever: dedicated RNG stream tags so the explore draw can never
78
+ # collide with (or shift) a generator's PCG64 stream or another lever's draw.
79
+ EXPLORE_STREAM_LOOP = 7919
80
+ EXPLORE_STREAM_FEEDBACK = 104729
81
+
82
+ # --------------------------------------------------------------------------- #
83
+ # Reject-inference correctors (cldd.reject_inference)
84
+ # --------------------------------------------------------------------------- #
85
+ # RI levers are graded against planted truth on a HELD-OUT fold of the declined
86
+ # population (the "recovery of this declined population's labels" claim). The
87
+ # pseudo-label fold and the parcelling label draw get dedicated RNG stream tags,
88
+ # same discipline as the exploration lever, so they can never shift a generator's
89
+ # PCG64 stream or another lever's draw.
90
+ RI_EVAL_FRACTION = 0.5 # fraction of declines held out for grading
91
+ RI_SCORE_BANDS = 10 # quantile score bands for augmentation / parcelling
92
+ RI_PARCEL_FACTOR = 1.5 # parcelling: bad-rate uplift over the accepted band rate
93
+ RI_SPLIT_STREAM = 15485863 # declined train/eval split
94
+ RI_PARCEL_STREAM = 32452843 # parcelling pseudo-label draw
cldd/correctors.py ADDED
@@ -0,0 +1,172 @@
1
+ """Pluggable correction levers for the closed loop.
2
+
3
+ Each correction lever the loop can apply is a :class:`Corrector` subclass:
4
+ "adding a lever is adding a class". A corrector takes a cohort plus a
5
+ :class:`CorrectorContext` (the per-round scalars + a closure that mints the
6
+ disjoint train cohort) and returns a :class:`CorrectionOutcome` (its
7
+ :class:`LeverMetrics` plus a small ``info`` dict of lever-specific extras).
8
+
9
+ The four shipped correctors are verbatim extractions of the lever bodies that
10
+ used to live as ``_measure_*`` methods on ``SelectiveLabelsLoop`` — identical
11
+ numpy calls, RNG seeds, sklearn calls and ordering — so the loop's numeric
12
+ output is unchanged.
13
+
14
+ This module imports only ``config``, ``eval_default``, ``model_pd`` and numpy;
15
+ it must NOT import ``loop`` (loop re-exports ``LeverMetrics`` from here, so the
16
+ reverse dependency would be circular).
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from abc import ABC, abstractmethod
22
+ from collections.abc import Callable
23
+ from dataclasses import dataclass, field
24
+
25
+ import numpy as np
26
+
27
+ from . import config, eval_default, model_pd
28
+
29
+
30
+ @dataclass
31
+ class LeverMetrics:
32
+ """Declined-subpopulation focus + the all-population calibration for one lever."""
33
+
34
+ declined_ece: float
35
+ declined_auc: float
36
+ declined_brier: float
37
+ declined_mean_pd: float
38
+ declined_base_rate: float
39
+ declined_f1: float
40
+ all_ece: float
41
+
42
+ @classmethod
43
+ def from_scored(cls, scored: dict) -> "LeverMetrics":
44
+ d = scored["declined"]
45
+ a = scored["all"]
46
+ return cls(
47
+ declined_ece=d.ece,
48
+ declined_auc=d.auc,
49
+ declined_brier=d.brier,
50
+ declined_mean_pd=d.mean_pd,
51
+ declined_base_rate=d.base_rate,
52
+ declined_f1=d.f1,
53
+ all_ece=a.ece,
54
+ )
55
+
56
+
57
+ @dataclass(frozen=True)
58
+ class CorrectorContext:
59
+ """Per-round scalars a corrector may need, plus the train-cohort closure.
60
+
61
+ ``make_train_cohort`` returns ``(train_cohort, train_seed)`` — the loop
62
+ supplies a closure over ``self._generate_train(severity, iteration)`` so the
63
+ no-leakage disjoint-train discipline stays inside the loop.
64
+ """
65
+
66
+ seed: int
67
+ policy_threshold: float
68
+ severity: float
69
+ iteration: int
70
+ exploration_rate: float
71
+ make_train_cohort: Callable[[], tuple[dict, int]]
72
+
73
+
74
+ @dataclass
75
+ class CorrectionOutcome:
76
+ """A corrector's metrics plus its lever-specific extras.
77
+
78
+ ``info`` is ``{}`` (naive/reweight), ``{"train_seed": int}`` (retrain), or
79
+ ``{"n_explored": int, "explored_defaults": int}`` (explore).
80
+ """
81
+
82
+ metrics: LeverMetrics
83
+ info: dict = field(default_factory=dict)
84
+
85
+
86
+ class Corrector(ABC):
87
+ """A single pluggable correction lever.
88
+
89
+ ``name`` keys the lever in ``RoundResult.corrections``; ``control_priority``
90
+ decides which present corrector drives loop control (highest wins; ties go to
91
+ the first in the loop's corrector list).
92
+ """
93
+
94
+ name: str = ""
95
+ control_priority: int = 0
96
+
97
+ @abstractmethod
98
+ def apply(self, cohort: dict, ctx: CorrectorContext) -> CorrectionOutcome:
99
+ ...
100
+
101
+
102
+ class NaiveCorrector(Corrector):
103
+ """The baseline detector: PD model fitted on approved rows only."""
104
+
105
+ name = "naive"
106
+ control_priority = 0
107
+
108
+ def apply(self, cohort: dict, ctx: CorrectorContext) -> CorrectionOutcome:
109
+ model = eval_default.fit_observed_model(cohort, random_state=ctx.seed)
110
+ metrics = LeverMetrics.from_scored(
111
+ eval_default.score_pd_detection(model, cohort, ctx.policy_threshold)
112
+ )
113
+ return CorrectionOutcome(metrics=metrics)
114
+
115
+
116
+ class IPWReweightCorrector(Corrector):
117
+ """Inverse-propensity reweighting of the approved training rows."""
118
+
119
+ name = "reweight"
120
+ control_priority = 2
121
+
122
+ def apply(self, cohort: dict, ctx: CorrectorContext) -> CorrectionOutcome:
123
+ X = cohort["features"].to_numpy(dtype=float)
124
+ approved = cohort["approved"]
125
+ weights = model_pd.selection_adjusted_weights(X, approved, random_state=ctx.seed)
126
+ model = eval_default.fit_observed_model(
127
+ cohort, sample_weight=weights[approved], random_state=ctx.seed
128
+ )
129
+ metrics = LeverMetrics.from_scored(
130
+ eval_default.score_pd_detection(model, cohort, ctx.policy_threshold)
131
+ )
132
+ return CorrectionOutcome(metrics=metrics)
133
+
134
+
135
+ class DisjointRetrainCorrector(Corrector):
136
+ """Refit on a disjoint train cohort, score on the held-out measure cohort."""
137
+
138
+ name = "retrain"
139
+ control_priority = 1
140
+
141
+ def apply(self, cohort: dict, ctx: CorrectorContext) -> CorrectionOutcome:
142
+ train_cohort, train_seed = ctx.make_train_cohort()
143
+ model = eval_default.fit_observed_model(train_cohort, random_state=train_seed)
144
+ scored = eval_default.score_pd_detection(model, cohort, ctx.policy_threshold)
145
+ metrics = LeverMetrics.from_scored(scored)
146
+ return CorrectionOutcome(metrics=metrics, info={"train_seed": train_seed})
147
+
148
+
149
+ class ExplorationCorrector(Corrector):
150
+ """Buy labels on a random slice of the declines and train with exact weights."""
151
+
152
+ name = "explore"
153
+ control_priority = 3
154
+
155
+ def apply(self, cohort: dict, ctx: CorrectorContext) -> CorrectionOutcome:
156
+ rng = np.random.default_rng([ctx.seed, ctx.iteration, config.EXPLORE_STREAM_LOOP])
157
+ approved = cohort["approved"]
158
+ explored = (~approved) & (rng.random(approved.shape[0]) < ctx.exploration_rate)
159
+ funded = approved | explored
160
+
161
+ X = cohort["features"].to_numpy(dtype=float)
162
+ y = cohort["true_default"]
163
+ weights = np.where(explored, 1.0 / ctx.exploration_rate, 1.0)
164
+ model = model_pd.train_pd_model(
165
+ X[funded], y[funded], sample_weight=weights[funded], random_state=ctx.seed
166
+ )
167
+ scored = eval_default.score_pd_detection(model, cohort, ctx.policy_threshold, funded=funded)
168
+ metrics = LeverMetrics.from_scored(scored)
169
+ return CorrectionOutcome(
170
+ metrics=metrics,
171
+ info={"n_explored": int(explored.sum()), "explored_defaults": int(y[explored].sum())},
172
+ )