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/fidelity.py
ADDED
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
"""Fidelity gate: real-vs-synthetic marginal comparison for the SCM generator.
|
|
2
|
+
|
|
3
|
+
This is the upstream **VERIFY FIDELITY** stage. Before the closed loop trusts a
|
|
4
|
+
:class:`cldd.scm.StructuralBorrowerGenerator` cohort as a stand-in for the real
|
|
5
|
+
Intuit SMB underwriting data, we check that the synthetic marginals actually look
|
|
6
|
+
like the real ones. The gate compares, per quantity:
|
|
7
|
+
|
|
8
|
+
* the **labeled default base rate** (real: only prior-approved/matured loans carry
|
|
9
|
+
a ``default_flag``; we drop nulls — that labeled pool ran ~17.4%),
|
|
10
|
+
* the **has_linked_bank_feed rate**,
|
|
11
|
+
* the **bank-feed missingness** (fraction of rows with a null bank-feed column),
|
|
12
|
+
* for each modeled **continuous** feature, the p1 / p50 / p99 quantiles, and
|
|
13
|
+
* for each modeled **categorical** feature, the top-category frequencies.
|
|
14
|
+
|
|
15
|
+
Each check carries a pass/fail against a tolerance:
|
|
16
|
+
|
|
17
|
+
* base rate ............ absolute +/- 0.03
|
|
18
|
+
* feed rate ............ absolute +/- 0.05
|
|
19
|
+
* missingness .......... absolute +/- 0.05
|
|
20
|
+
* continuous quantiles . within 30% *relative* for p50/p99 (50% for the noisier
|
|
21
|
+
p1 left tail), measured against a standard-deviation
|
|
22
|
+
floor so near-zero reference quantiles (e.g. revenue
|
|
23
|
+
trend, centered on ~0) are judged on the feature's spread
|
|
24
|
+
rather than its near-zero level
|
|
25
|
+
|
|
26
|
+
Categorical top-frequency checks are reported (and tolerance-checked at +/- 0.05
|
|
27
|
+
absolute) but, because the SCM categoricals are exogenous draws calibrated to the
|
|
28
|
+
real level probabilities, they default to *informational* unless you flip
|
|
29
|
+
``strict_categoricals=True``.
|
|
30
|
+
|
|
31
|
+
**Scope (what this does NOT check):** every check here is a *univariate marginal*
|
|
32
|
+
comparison. The gate does **not** test the joint distribution, feature
|
|
33
|
+
correlations, or the causal/DAG structure of the SCM -- a synthetic cohort can
|
|
34
|
+
match every marginal above and still get the joint wrong. Read "MARGINAL FIDELITY
|
|
35
|
+
PASSED" as "the modeled marginals match," not "the generator is faithful in full."
|
|
36
|
+
|
|
37
|
+
Nothing here mutates the generator or touches other modules; import via
|
|
38
|
+
``cldd.fidelity`` (the package ``__init__`` is intentionally left alone).
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
from __future__ import annotations
|
|
42
|
+
|
|
43
|
+
import os
|
|
44
|
+
from dataclasses import dataclass, field
|
|
45
|
+
from pathlib import Path
|
|
46
|
+
|
|
47
|
+
import numpy as np
|
|
48
|
+
import pandas as pd
|
|
49
|
+
|
|
50
|
+
from .scm import StructuralBorrowerGenerator
|
|
51
|
+
|
|
52
|
+
# --------------------------------------------------------------------------- #
|
|
53
|
+
# Configuration
|
|
54
|
+
# --------------------------------------------------------------------------- #
|
|
55
|
+
|
|
56
|
+
#: Historical author-machine dataset location, kept only as a last-resort fallback
|
|
57
|
+
#: so the maintainer's local ``check_fidelity`` run needs no configuration.
|
|
58
|
+
_FALLBACK_DATA_DIR = Path(
|
|
59
|
+
"C:/Users/hossa/dev/intuit-techweek-hackathon/"
|
|
60
|
+
"intuit-techweek-nyc-hackathon-2026/dataset"
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
#: Default location of the real Intuit dataset. The dataset is **private and not
|
|
64
|
+
#: shipped with the package**, so the fidelity gate is runnable only where that
|
|
65
|
+
#: data is available. Point the gate at your copy with the ``CLDD_DATA_DIR``
|
|
66
|
+
#: environment variable (the portable knob) or by passing ``data_dir=`` explicitly;
|
|
67
|
+
#: absent both, this falls back to the maintainer's historical path.
|
|
68
|
+
DEFAULT_DATA_DIR = Path(os.environ.get("CLDD_DATA_DIR", _FALLBACK_DATA_DIR))
|
|
69
|
+
|
|
70
|
+
#: Continuous features compared on p1/p50/p99 (modeled in the SCM marginals).
|
|
71
|
+
CONTINUOUS_FEATURES = (
|
|
72
|
+
"stated_annual_revenue",
|
|
73
|
+
"stated_time_in_business",
|
|
74
|
+
"requested_amount",
|
|
75
|
+
"observed_monthly_revenue_avg_3mo",
|
|
76
|
+
"observed_revenue_trend_3mo",
|
|
77
|
+
"observed_revenue_volatility",
|
|
78
|
+
"observed_cash_balance_p10",
|
|
79
|
+
"aggregate_credit_utilization",
|
|
80
|
+
"existing_debt_obligations",
|
|
81
|
+
"invoice_payment_delinquency_rate",
|
|
82
|
+
"vintage_years",
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
#: Categorical features compared on top-category frequencies.
|
|
86
|
+
CATEGORICAL_FEATURES = (
|
|
87
|
+
"sector",
|
|
88
|
+
"geography_region",
|
|
89
|
+
"employee_count_bucket",
|
|
90
|
+
"owner_personal_credit_band",
|
|
91
|
+
"application_channel",
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
#: One bank-feed column whose null-fraction defines bank-feed missingness.
|
|
95
|
+
_MISSINGNESS_PROBE = "observed_monthly_revenue_avg_3mo"
|
|
96
|
+
|
|
97
|
+
#: Tolerances.
|
|
98
|
+
BASE_RATE_TOL = 0.03 # absolute
|
|
99
|
+
FEED_RATE_TOL = 0.05 # absolute
|
|
100
|
+
MISSINGNESS_TOL = 0.05 # absolute
|
|
101
|
+
QUANTILE_REL_TOL = 0.30 # relative (fraction), for p50/p99
|
|
102
|
+
QUANTILE_TAIL_REL_TOL = 0.50 # relative (fraction), for the noisier p1 left tail
|
|
103
|
+
QUANTILE_STD_FLOOR = 1.0 # absolute band = tol * max(|real|, STD_FLOOR*std)
|
|
104
|
+
CATEGORICAL_TOL = 0.05 # absolute, per top category
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
# --------------------------------------------------------------------------- #
|
|
108
|
+
# Report dataclasses
|
|
109
|
+
# --------------------------------------------------------------------------- #
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@dataclass
|
|
113
|
+
class FidelityCheck:
|
|
114
|
+
"""One real-vs-synthetic comparison with a pass/fail verdict."""
|
|
115
|
+
|
|
116
|
+
name: str
|
|
117
|
+
real: float
|
|
118
|
+
synth: float
|
|
119
|
+
tolerance: float
|
|
120
|
+
kind: str # "abs" or "rel"
|
|
121
|
+
passed: bool
|
|
122
|
+
counts: bool = True # does this check count toward the overall verdict?
|
|
123
|
+
|
|
124
|
+
@property
|
|
125
|
+
def diff(self) -> float:
|
|
126
|
+
return float(self.synth - self.real)
|
|
127
|
+
|
|
128
|
+
@property
|
|
129
|
+
def rel_diff(self) -> float:
|
|
130
|
+
denom = abs(self.real) if abs(self.real) > 1e-12 else 1e-12
|
|
131
|
+
return float((self.synth - self.real) / denom)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
@dataclass
|
|
135
|
+
class FidelityReport:
|
|
136
|
+
"""Collection of fidelity checks plus an overall pass/fail."""
|
|
137
|
+
|
|
138
|
+
checks: list[FidelityCheck] = field(default_factory=list)
|
|
139
|
+
real_n_labeled: int = 0
|
|
140
|
+
synth_n: int = 0
|
|
141
|
+
data_dir: str = ""
|
|
142
|
+
|
|
143
|
+
@property
|
|
144
|
+
def counting_checks(self) -> list[FidelityCheck]:
|
|
145
|
+
return [c for c in self.checks if c.counts]
|
|
146
|
+
|
|
147
|
+
@property
|
|
148
|
+
def passed(self) -> bool:
|
|
149
|
+
return all(c.passed for c in self.counting_checks)
|
|
150
|
+
|
|
151
|
+
@property
|
|
152
|
+
def n_failed(self) -> int:
|
|
153
|
+
return sum(not c.passed for c in self.counting_checks)
|
|
154
|
+
|
|
155
|
+
def get(self, name: str) -> FidelityCheck:
|
|
156
|
+
for c in self.checks:
|
|
157
|
+
if c.name == name:
|
|
158
|
+
return c
|
|
159
|
+
raise KeyError(name)
|
|
160
|
+
|
|
161
|
+
def to_table(self) -> str:
|
|
162
|
+
"""Human-readable fixed-width table of every check."""
|
|
163
|
+
header = (
|
|
164
|
+
f"MARGINAL FIDELITY REPORT (real labeled n={self.real_n_labeled:,}, "
|
|
165
|
+
f"synthetic n={self.synth_n:,})\n"
|
|
166
|
+
f"scope: univariate marginals only -- does NOT check the joint/causal "
|
|
167
|
+
f"distribution\n"
|
|
168
|
+
f"data_dir: {self.data_dir}\n"
|
|
169
|
+
)
|
|
170
|
+
cols = ("check", "real", "synth", "diff", "tol", "verdict")
|
|
171
|
+
rows = [cols]
|
|
172
|
+
for c in self.checks:
|
|
173
|
+
if c.kind == "rel":
|
|
174
|
+
diff_s = f"{c.rel_diff:+.1%}"
|
|
175
|
+
tol_s = f"<{c.tolerance:.0%} rel"
|
|
176
|
+
else:
|
|
177
|
+
diff_s = f"{c.diff:+.4f}"
|
|
178
|
+
tol_s = f"+/-{c.tolerance:.3f}"
|
|
179
|
+
verdict = "PASS" if c.passed else "FAIL"
|
|
180
|
+
if not c.counts:
|
|
181
|
+
verdict += "*"
|
|
182
|
+
rows.append(
|
|
183
|
+
(c.name, f"{c.real:.4f}", f"{c.synth:.4f}", diff_s, tol_s, verdict)
|
|
184
|
+
)
|
|
185
|
+
widths = [max(len(r[i]) for r in rows) for i in range(len(cols))]
|
|
186
|
+
lines = []
|
|
187
|
+
for ri, r in enumerate(rows):
|
|
188
|
+
line = " ".join(r[i].ljust(widths[i]) for i in range(len(cols)))
|
|
189
|
+
lines.append(line)
|
|
190
|
+
if ri == 0:
|
|
191
|
+
lines.append(" ".join("-" * widths[i] for i in range(len(cols))))
|
|
192
|
+
overall = "PASSED" if self.passed else f"FAILED ({self.n_failed} check(s))"
|
|
193
|
+
footer = (
|
|
194
|
+
f"\nOVERALL: {overall} "
|
|
195
|
+
f"(* = informational, not counted toward verdict)"
|
|
196
|
+
)
|
|
197
|
+
return header + "\n" + "\n".join(lines) + "\n" + footer
|
|
198
|
+
|
|
199
|
+
def get_score(self) -> float:
|
|
200
|
+
"""SDMetrics-style 0..1 quality score.
|
|
201
|
+
|
|
202
|
+
The score is the fraction of **counting** checks that passed -- i.e.
|
|
203
|
+
``len(passed counting checks) / len(counting_checks)``. Informational
|
|
204
|
+
checks (``counts=False``) are excluded, matching how :attr:`passed` and
|
|
205
|
+
:attr:`n_failed` treat them. Returns ``float('nan')`` when there are no
|
|
206
|
+
counting checks (nothing to score).
|
|
207
|
+
"""
|
|
208
|
+
counting = self.counting_checks
|
|
209
|
+
if not counting:
|
|
210
|
+
return float("nan")
|
|
211
|
+
n_passed = sum(c.passed for c in counting)
|
|
212
|
+
return float(n_passed) / float(len(counting))
|
|
213
|
+
|
|
214
|
+
def get_details(self) -> pd.DataFrame:
|
|
215
|
+
"""SDMetrics-style per-check detail table as a DataFrame.
|
|
216
|
+
|
|
217
|
+
One row per check (all checks, counting and informational) with columns
|
|
218
|
+
``[check, real, synth, diff, tolerance, kind, passed, counts]``. The
|
|
219
|
+
``diff`` column uses each check's signed ``.diff`` for ``kind=="abs"``
|
|
220
|
+
checks and its signed ``.rel_diff`` for ``kind=="rel"`` checks, so the
|
|
221
|
+
magnitude is directly comparable to ``tolerance`` for that row.
|
|
222
|
+
"""
|
|
223
|
+
rows = []
|
|
224
|
+
for c in self.checks:
|
|
225
|
+
diff = c.rel_diff if c.kind == "rel" else c.diff
|
|
226
|
+
rows.append(
|
|
227
|
+
{
|
|
228
|
+
"check": c.name,
|
|
229
|
+
"real": c.real,
|
|
230
|
+
"synth": c.synth,
|
|
231
|
+
"diff": diff,
|
|
232
|
+
"tolerance": c.tolerance,
|
|
233
|
+
"kind": c.kind,
|
|
234
|
+
"passed": c.passed,
|
|
235
|
+
"counts": c.counts,
|
|
236
|
+
}
|
|
237
|
+
)
|
|
238
|
+
columns = [
|
|
239
|
+
"check", "real", "synth", "diff", "tolerance", "kind", "passed", "counts",
|
|
240
|
+
]
|
|
241
|
+
return pd.DataFrame(rows, columns=columns)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
# --------------------------------------------------------------------------- #
|
|
245
|
+
# Loaders
|
|
246
|
+
# --------------------------------------------------------------------------- #
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def load_real(data_dir: str | Path = DEFAULT_DATA_DIR, split: str = "train") -> pd.DataFrame:
|
|
250
|
+
"""Load a split of the real Intuit dataset as a DataFrame.
|
|
251
|
+
|
|
252
|
+
Parameters
|
|
253
|
+
----------
|
|
254
|
+
data_dir : path to the dataset directory (defaults to :data:`DEFAULT_DATA_DIR`).
|
|
255
|
+
split : one of ``"train"``, ``"validation"``, ``"test"`` (file ``<split>.csv``).
|
|
256
|
+
"""
|
|
257
|
+
path = Path(data_dir) / f"{split}.csv"
|
|
258
|
+
if not path.exists():
|
|
259
|
+
raise FileNotFoundError(
|
|
260
|
+
f"real Intuit dataset not found: {path}. The dataset is private and is "
|
|
261
|
+
f"not shipped with cldd; set the CLDD_DATA_DIR environment variable (or "
|
|
262
|
+
f"pass data_dir=) to the directory containing {split}.csv."
|
|
263
|
+
)
|
|
264
|
+
return pd.read_csv(path)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def load_synthetic(
|
|
268
|
+
n_applicants: int = 12000,
|
|
269
|
+
seed: int = 42,
|
|
270
|
+
generator: StructuralBorrowerGenerator | None = None,
|
|
271
|
+
**generator_kwargs,
|
|
272
|
+
) -> dict:
|
|
273
|
+
"""Build (or accept) a :class:`StructuralBorrowerGenerator` cohort dict.
|
|
274
|
+
|
|
275
|
+
Pass a pre-built ``generator`` to reuse it, or let this construct one with the
|
|
276
|
+
given ``n_applicants`` / ``seed`` / extra ``generator_kwargs``.
|
|
277
|
+
"""
|
|
278
|
+
if generator is None:
|
|
279
|
+
generator = StructuralBorrowerGenerator(
|
|
280
|
+
n_applicants=n_applicants, seed=seed, **generator_kwargs
|
|
281
|
+
)
|
|
282
|
+
return generator.generate_cohort()
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
# --------------------------------------------------------------------------- #
|
|
286
|
+
# Per-quantity computation
|
|
287
|
+
# --------------------------------------------------------------------------- #
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _real_base_rate(real: pd.DataFrame) -> tuple[float, int]:
|
|
291
|
+
"""Labeled default base rate over rows that actually carry a ``default_flag``."""
|
|
292
|
+
s = real["default_flag"].dropna()
|
|
293
|
+
return float(s.mean()), int(len(s))
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _quantile_check(name: str, real_vals, synth_vals, q: float, tag: str) -> FidelityCheck:
|
|
297
|
+
rv = np.asarray(real_vals, dtype=float)
|
|
298
|
+
rq = float(np.nanquantile(rv, q))
|
|
299
|
+
sq = float(np.nanquantile(np.asarray(synth_vals, dtype=float), q))
|
|
300
|
+
# Relative tolerance with a *standard-deviation* floor: a quantile sitting near
|
|
301
|
+
# zero (e.g. revenue trend, which is centered on ~0) is judged against a band
|
|
302
|
+
# that scales with the feature's spread, not its (near-zero) level. So the
|
|
303
|
+
# absolute pass-band is ``tol * max(|real|, std)`` -- equivalently "within tol
|
|
304
|
+
# relative, OR within tol of one standard deviation". The p1 left tail is
|
|
305
|
+
# intrinsically noisier than p50/p99, so it gets the wider tail tolerance.
|
|
306
|
+
std = float(np.nanstd(rv))
|
|
307
|
+
tol = QUANTILE_TAIL_REL_TOL if tag == "p1" else QUANTILE_REL_TOL
|
|
308
|
+
band = tol * max(abs(rq), QUANTILE_STD_FLOOR * std)
|
|
309
|
+
passed = abs(sq - rq) <= band
|
|
310
|
+
return FidelityCheck(
|
|
311
|
+
name=name, real=rq, synth=sq, tolerance=tol,
|
|
312
|
+
kind="rel", passed=passed, counts=True,
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def _top_category_checks(
|
|
317
|
+
name: str, real_series: pd.Series, synth_series: pd.Series, top_k: int, counts: bool
|
|
318
|
+
) -> list[FidelityCheck]:
|
|
319
|
+
real_freq = real_series.dropna().round().astype(int).value_counts(normalize=True)
|
|
320
|
+
synth_freq = synth_series.dropna().round().astype(int).value_counts(normalize=True)
|
|
321
|
+
checks: list[FidelityCheck] = []
|
|
322
|
+
for level in list(real_freq.index[:top_k]):
|
|
323
|
+
rf = float(real_freq.get(level, 0.0))
|
|
324
|
+
sf = float(synth_freq.get(level, 0.0))
|
|
325
|
+
checks.append(
|
|
326
|
+
FidelityCheck(
|
|
327
|
+
name=f"{name}=={level} freq", real=rf, synth=sf,
|
|
328
|
+
tolerance=CATEGORICAL_TOL, kind="abs",
|
|
329
|
+
passed=abs(sf - rf) <= CATEGORICAL_TOL, counts=counts,
|
|
330
|
+
)
|
|
331
|
+
)
|
|
332
|
+
return checks
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
# --------------------------------------------------------------------------- #
|
|
336
|
+
# Top-level entry point
|
|
337
|
+
# --------------------------------------------------------------------------- #
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def compute_fidelity(
|
|
341
|
+
real: pd.DataFrame,
|
|
342
|
+
cohort: dict,
|
|
343
|
+
*,
|
|
344
|
+
strict_categoricals: bool = False,
|
|
345
|
+
) -> FidelityReport:
|
|
346
|
+
"""Compare a real DataFrame against an SCM cohort dict; return a report.
|
|
347
|
+
|
|
348
|
+
Parameters
|
|
349
|
+
----------
|
|
350
|
+
real : real dataset split (e.g. from :func:`load_real`).
|
|
351
|
+
cohort : an SCM cohort dict (from :func:`load_synthetic` / ``generate_cohort``).
|
|
352
|
+
strict_categoricals : count categorical frequency checks toward the verdict.
|
|
353
|
+
"""
|
|
354
|
+
synth = cohort["features"]
|
|
355
|
+
report = FidelityReport(synth_n=len(synth))
|
|
356
|
+
|
|
357
|
+
# ---- labeled default base rate (absolute) ----
|
|
358
|
+
real_base, n_labeled = _real_base_rate(real)
|
|
359
|
+
report.real_n_labeled = n_labeled
|
|
360
|
+
synth_base = float(np.asarray(cohort["true_default"]).mean())
|
|
361
|
+
report.checks.append(
|
|
362
|
+
FidelityCheck(
|
|
363
|
+
name="default_base_rate", real=real_base, synth=synth_base,
|
|
364
|
+
tolerance=BASE_RATE_TOL, kind="abs",
|
|
365
|
+
passed=abs(synth_base - real_base) <= BASE_RATE_TOL,
|
|
366
|
+
)
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
# ---- has_linked_bank_feed rate (absolute) ----
|
|
370
|
+
real_feed = float(real["has_linked_bank_feed"].mean())
|
|
371
|
+
synth_feed = float((synth["has_linked_bank_feed"] > 0.5).mean())
|
|
372
|
+
report.checks.append(
|
|
373
|
+
FidelityCheck(
|
|
374
|
+
name="has_linked_bank_feed_rate", real=real_feed, synth=synth_feed,
|
|
375
|
+
tolerance=FEED_RATE_TOL, kind="abs",
|
|
376
|
+
passed=abs(synth_feed - real_feed) <= FEED_RATE_TOL,
|
|
377
|
+
)
|
|
378
|
+
)
|
|
379
|
+
|
|
380
|
+
# ---- bank-feed missingness (absolute) ----
|
|
381
|
+
real_miss = float(real[_MISSINGNESS_PROBE].isna().mean())
|
|
382
|
+
synth_miss = float(synth[_MISSINGNESS_PROBE].isna().mean())
|
|
383
|
+
report.checks.append(
|
|
384
|
+
FidelityCheck(
|
|
385
|
+
name="bank_feed_missingness", real=real_miss, synth=synth_miss,
|
|
386
|
+
tolerance=MISSINGNESS_TOL, kind="abs",
|
|
387
|
+
passed=abs(synth_miss - real_miss) <= MISSINGNESS_TOL,
|
|
388
|
+
)
|
|
389
|
+
)
|
|
390
|
+
|
|
391
|
+
# ---- continuous quantiles (relative) ----
|
|
392
|
+
for feat in CONTINUOUS_FEATURES:
|
|
393
|
+
if feat not in real.columns or feat not in synth.columns:
|
|
394
|
+
continue
|
|
395
|
+
rv = real[feat].to_numpy()
|
|
396
|
+
sv = synth[feat].to_numpy()
|
|
397
|
+
for q, tag in ((0.01, "p1"), (0.50, "p50"), (0.99, "p99")):
|
|
398
|
+
report.checks.append(_quantile_check(f"{feat} {tag}", rv, sv, q, tag))
|
|
399
|
+
|
|
400
|
+
# ---- categorical top-frequencies (informational unless strict) ----
|
|
401
|
+
for feat in CATEGORICAL_FEATURES:
|
|
402
|
+
if feat not in real.columns or feat not in synth.columns:
|
|
403
|
+
continue
|
|
404
|
+
report.checks.extend(
|
|
405
|
+
_top_category_checks(
|
|
406
|
+
feat, real[feat], synth[feat], top_k=3, counts=strict_categoricals
|
|
407
|
+
)
|
|
408
|
+
)
|
|
409
|
+
|
|
410
|
+
return report
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def run_fidelity_gate(
|
|
414
|
+
data_dir: str | Path = DEFAULT_DATA_DIR,
|
|
415
|
+
split: str = "train",
|
|
416
|
+
n_applicants: int = 12000,
|
|
417
|
+
seed: int = 42,
|
|
418
|
+
*,
|
|
419
|
+
generator: StructuralBorrowerGenerator | None = None,
|
|
420
|
+
strict_categoricals: bool = False,
|
|
421
|
+
**generator_kwargs,
|
|
422
|
+
) -> FidelityReport:
|
|
423
|
+
"""Load real data + build a cohort + compute the complete marginal-fidelity report."""
|
|
424
|
+
real = load_real(data_dir, split=split)
|
|
425
|
+
cohort = load_synthetic(
|
|
426
|
+
n_applicants=n_applicants, seed=seed, generator=generator, **generator_kwargs
|
|
427
|
+
)
|
|
428
|
+
report = compute_fidelity(real, cohort, strict_categoricals=strict_categoricals)
|
|
429
|
+
report.data_dir = str(data_dir)
|
|
430
|
+
return report
|