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/__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
|
+
)
|