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