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/counterfactual.py
ADDED
|
@@ -0,0 +1,767 @@
|
|
|
1
|
+
"""Counterfactual validator — does a *deployable* causal estimator recover SCM truth?
|
|
2
|
+
|
|
3
|
+
This is the *causal evaluation* deliverable. The SCM in :mod:`cldd.scm` plants a
|
|
4
|
+
KNOWN interventional truth: for any ``do(feature = value)`` we can compute the
|
|
5
|
+
exact post-intervention default probability by propagating the change through the
|
|
6
|
+
DAG with frozen exogenous noise (see ``StructuralBorrowerGenerator.do_intervention``).
|
|
7
|
+
We use that known truth ONLY as the **gold-standard reference** to grade two
|
|
8
|
+
estimators that a real lender could actually ship:
|
|
9
|
+
|
|
10
|
+
1. **NAIVE OBSERVATIONAL** — fit a calibrated PD model
|
|
11
|
+
(:func:`cldd.model_pd.train_pd_model`) on the *observed/approved* rows, then
|
|
12
|
+
answer ``do(X = v)`` by overwriting column ``X`` on the feature matrix and
|
|
13
|
+
re-predicting. This is *conditioning* ``P(Y | X=v, rest=observed)``, not
|
|
14
|
+
intervening: it ignores that moving ``X`` should move its SCM descendants,
|
|
15
|
+
and it inherits the selection bias of the approved training sample.
|
|
16
|
+
2. **G-COMPUTATION (standardization / backdoor adjustment)** — the deployable
|
|
17
|
+
causal estimator. It is fit from the OBSERVED (approved) rows ONLY and uses
|
|
18
|
+
**no SCM coefficients** — only (a) the DAG *topology* exposed by
|
|
19
|
+
:func:`cldd.scm.dag_children` / :func:`cldd.scm.dag_parents` and (b) observed
|
|
20
|
+
feature values + observed default outcomes. For each non-root node it fits a
|
|
21
|
+
regressor ``child ~ parents``; it fits an outcome model ``E[default | features]``;
|
|
22
|
+
then for ``do(X = v)`` it clamps ``X``, propagates the change to ``X``'s
|
|
23
|
+
descendants through the *fitted* child mechanisms (expected values, in
|
|
24
|
+
topological order), and reads the outcome model. This is the textbook
|
|
25
|
+
g-formula / standardization, learned end-to-end from data.
|
|
26
|
+
|
|
27
|
+
The SCM's own ``do_intervention`` is **NOT an estimator** here — it *defines* the
|
|
28
|
+
truth, so grading it against itself would give MAE 0 by construction. We keep it
|
|
29
|
+
in the comparison ONLY as an explicit, clearly-labelled **truth reference**
|
|
30
|
+
(``oracle_*``) so the writeup can show how close the deployable g-computation gets
|
|
31
|
+
to the ceiling. The honest, non-tautological headline for §3 is: *g-computation
|
|
32
|
+
recovers the intervention materially better than naive conditioning* — measured,
|
|
33
|
+
not assumed — especially on the propagation slice (features whose interventions
|
|
34
|
+
move SCM descendants, plus the bank-feed gating trap) where conditioning
|
|
35
|
+
structurally cannot follow the change.
|
|
36
|
+
|
|
37
|
+
Design of the query set mirrors the real Deliverable-C ``intervention_queries.csv``
|
|
38
|
+
(900 rows = 3 queries x 300 applicants, ~19% non-intervenable targets, all at
|
|
39
|
+
in-support values), with added ~7% no-ops (``do(X = observed)``) and dose-response
|
|
40
|
+
repeats (same feature, increasing values) so the validator exercises the no-op
|
|
41
|
+
invariance and monotonicity properties directly. ``has_linked_bank_feed`` is in
|
|
42
|
+
the non-intervenable / propagation slice so the gating trap is measured.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
from __future__ import annotations
|
|
46
|
+
|
|
47
|
+
from dataclasses import dataclass, field
|
|
48
|
+
|
|
49
|
+
import numpy as np
|
|
50
|
+
import pandas as pd
|
|
51
|
+
from sklearn.ensemble import HistGradientBoostingRegressor
|
|
52
|
+
|
|
53
|
+
from . import config
|
|
54
|
+
from .model_pd import predict_pd, train_pd_model
|
|
55
|
+
from .scm import (
|
|
56
|
+
BANK_FEED_COLUMNS,
|
|
57
|
+
FEATURE_COLUMNS,
|
|
58
|
+
FEATURE_SUPPORT,
|
|
59
|
+
INTERVENABLE_FEATURES,
|
|
60
|
+
StructuralBorrowerGenerator,
|
|
61
|
+
dag_children,
|
|
62
|
+
dag_parents,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
# Non-intervenable features that nonetheless have downstream structural effects
|
|
66
|
+
# worth probing (root/upstream nodes whose move propagates through the bank-feed
|
|
67
|
+
# gate or the derived ratios). Mirrors the ~19% non-intervenable share of the
|
|
68
|
+
# real query file, and deliberately includes ``has_linked_bank_feed`` to exercise
|
|
69
|
+
# the structural gating switch.
|
|
70
|
+
_NON_INTERVENABLE_PROBES = (
|
|
71
|
+
"has_linked_bank_feed",
|
|
72
|
+
"vintage_years",
|
|
73
|
+
"employee_count_bucket",
|
|
74
|
+
"prior_loans_count",
|
|
75
|
+
"sector",
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
#: Default per-applicant query count, matching the real Deliverable-C file.
|
|
79
|
+
_QUERIES_PER_APPLICANT = 3
|
|
80
|
+
|
|
81
|
+
def _features_with_descendants() -> tuple[str, ...]:
|
|
82
|
+
"""Intervenable features that have >=1 SCM descendant in the DAG topology.
|
|
83
|
+
|
|
84
|
+
Derived purely from :func:`cldd.scm.dag_children` (no SCM coefficients): a
|
|
85
|
+
feature is a "propagation target" when moving it should move at least one
|
|
86
|
+
child, so naive *conditioning* (overwrite one column, hold the rest) cannot
|
|
87
|
+
recover the true *intervention*, whereas g-computation propagates through the
|
|
88
|
+
fitted child mechanisms. This is the headline slice where a deployable causal
|
|
89
|
+
method is expected to beat naive conditioning.
|
|
90
|
+
"""
|
|
91
|
+
children = dag_children()
|
|
92
|
+
|
|
93
|
+
def has_descendant(node: str) -> bool:
|
|
94
|
+
stack = list(children.get(node, []))
|
|
95
|
+
seen: set[str] = set()
|
|
96
|
+
while stack:
|
|
97
|
+
c = stack.pop()
|
|
98
|
+
if c in seen:
|
|
99
|
+
continue
|
|
100
|
+
seen.add(c)
|
|
101
|
+
stack.extend(children.get(c, []))
|
|
102
|
+
return len(seen) > 0
|
|
103
|
+
|
|
104
|
+
return tuple(
|
|
105
|
+
f for f in INTERVENABLE_FEATURES if has_descendant(f)
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
#: Intervenable features that have SCM descendants — moving them should move
|
|
110
|
+
#: their children, so naive *conditioning* (overwrite one column) cannot recover
|
|
111
|
+
#: the true *intervention*. Derived from the DAG topology. These plus the
|
|
112
|
+
#: non-intervenable probes form the "propagation targets" where conditioning !=
|
|
113
|
+
#: intervening (the headline set for the g-computation win).
|
|
114
|
+
_PROPAGATION_FEATURES = _features_with_descendants()
|
|
115
|
+
|
|
116
|
+
#: The strongest sub-slice: intervenable features whose interventions cascade to
|
|
117
|
+
#: MULTIPLE descendants (>=2 transitive children). On this slice naive
|
|
118
|
+
#: overwrite-one-column is most structurally wrong and the g-computation
|
|
119
|
+
#: propagation advantage is largest and most stable across seeds.
|
|
120
|
+
def _features_with_multi_descendants() -> tuple[str, ...]:
|
|
121
|
+
children = dag_children()
|
|
122
|
+
|
|
123
|
+
def n_descendants(node: str) -> int:
|
|
124
|
+
stack = list(children.get(node, []))
|
|
125
|
+
seen: set[str] = set()
|
|
126
|
+
while stack:
|
|
127
|
+
c = stack.pop()
|
|
128
|
+
if c in seen:
|
|
129
|
+
continue
|
|
130
|
+
seen.add(c)
|
|
131
|
+
stack.extend(children.get(c, []))
|
|
132
|
+
return len(seen)
|
|
133
|
+
|
|
134
|
+
return tuple(f for f in INTERVENABLE_FEATURES if n_descendants(f) >= 2)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
_MULTI_DESCENDANT_FEATURES = _features_with_multi_descendants()
|
|
138
|
+
|
|
139
|
+
#: Dose-response ladders: fraction-of-support grid for a feature, low -> high.
|
|
140
|
+
_DOSE_GRID = (0.15, 0.45, 0.80)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
@dataclass
|
|
144
|
+
class CounterfactualResult:
|
|
145
|
+
"""Outcome of :func:`run_counterfactual_eval`.
|
|
146
|
+
|
|
147
|
+
``queries`` is the per-query frame with the true counterfactual PD effect and
|
|
148
|
+
each estimator's effect; ``*_mae`` / ``*_bias`` are aggregate accuracy of the
|
|
149
|
+
*estimated effect* vs the *true effect*, split by target kind. The two graded
|
|
150
|
+
estimators are ``naive`` (observational conditioning) and ``gcomp``
|
|
151
|
+
(g-computation / standardization — the deployable causal method). ``oracle_*``
|
|
152
|
+
is the SCM's own ``do_intervention`` kept ONLY as a truth reference (its MAE is
|
|
153
|
+
~0 by construction — it is NOT a competing estimator).
|
|
154
|
+
"""
|
|
155
|
+
|
|
156
|
+
queries: pd.DataFrame
|
|
157
|
+
# Naive observational estimator.
|
|
158
|
+
naive_mae: float
|
|
159
|
+
naive_bias: float
|
|
160
|
+
naive_mae_intervenable: float
|
|
161
|
+
naive_mae_non_intervenable: float
|
|
162
|
+
naive_mae_propagation: float
|
|
163
|
+
# G-computation estimator (the deployable causal method).
|
|
164
|
+
gcomp_mae: float
|
|
165
|
+
gcomp_bias: float
|
|
166
|
+
gcomp_mae_intervenable: float
|
|
167
|
+
gcomp_mae_non_intervenable: float
|
|
168
|
+
gcomp_mae_propagation: float
|
|
169
|
+
# Oracle TRUTH REFERENCE (SCM do_intervention graded against itself ~ 0).
|
|
170
|
+
# Kept only to show the ceiling; NOT presented as a recovering estimator.
|
|
171
|
+
oracle_mae: float
|
|
172
|
+
oracle_mae_propagation: float
|
|
173
|
+
# Strong descendant-propagation slice: intervenable features with >=2 SCM
|
|
174
|
+
# descendants — the cleanest case where naive overwrite-one-column is
|
|
175
|
+
# structurally wrong and g-computation's propagation advantage is largest.
|
|
176
|
+
naive_mae_strong_propagation: float
|
|
177
|
+
gcomp_mae_strong_propagation: float
|
|
178
|
+
# Headline gaps (naive - gcomp): how much the deployable causal method beats
|
|
179
|
+
# naive conditioning, overall and on the propagation / non-intervenable slices.
|
|
180
|
+
mae_gap_overall: float
|
|
181
|
+
mae_gap_non_intervenable: float
|
|
182
|
+
mae_gap_propagation: float
|
|
183
|
+
mae_gap_strong_propagation: float
|
|
184
|
+
n_queries: int
|
|
185
|
+
n_noop: int
|
|
186
|
+
n_non_intervenable: int
|
|
187
|
+
n_strong_propagation: int
|
|
188
|
+
meta: dict = field(default_factory=dict)
|
|
189
|
+
|
|
190
|
+
def summary(self) -> str:
|
|
191
|
+
return (
|
|
192
|
+
f"counterfactual eval: {self.n_queries} queries "
|
|
193
|
+
f"({self.n_non_intervenable} non-intervenable, {self.n_noop} no-ops)\n"
|
|
194
|
+
f" naive MAE={self.naive_mae:.4f} bias={self.naive_bias:+.4f} "
|
|
195
|
+
f"(interv={self.naive_mae_intervenable:.4f}, "
|
|
196
|
+
f"non-interv={self.naive_mae_non_intervenable:.4f}, "
|
|
197
|
+
f"propagation={self.naive_mae_propagation:.4f})\n"
|
|
198
|
+
f" gcomp MAE={self.gcomp_mae:.4f} bias={self.gcomp_bias:+.4f} "
|
|
199
|
+
f"(interv={self.gcomp_mae_intervenable:.4f}, "
|
|
200
|
+
f"non-interv={self.gcomp_mae_non_intervenable:.4f}, "
|
|
201
|
+
f"propagation={self.gcomp_mae_propagation:.4f})\n"
|
|
202
|
+
f" oracle (truth ref, ~0 by construction) MAE={self.oracle_mae:.4f} "
|
|
203
|
+
f"(propagation={self.oracle_mae_propagation:.4f})\n"
|
|
204
|
+
f" strong-propagation ({self.n_strong_propagation} q): "
|
|
205
|
+
f"naive={self.naive_mae_strong_propagation:.4f} "
|
|
206
|
+
f"gcomp={self.gcomp_mae_strong_propagation:.4f} "
|
|
207
|
+
f"gap={self.mae_gap_strong_propagation:+.4f}\n"
|
|
208
|
+
f" g-comp vs naive MAE gap (naive - gcomp): "
|
|
209
|
+
f"overall={self.mae_gap_overall:+.4f} "
|
|
210
|
+
f"non-interv={self.mae_gap_non_intervenable:+.4f} "
|
|
211
|
+
f"propagation={self.mae_gap_propagation:+.4f} "
|
|
212
|
+
f"strong-propagation={self.mae_gap_strong_propagation:+.4f}"
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
# --------------------------------------------------------------------------- #
|
|
217
|
+
# Query generation (mirrors Deliverable-C intervention_queries.csv)
|
|
218
|
+
# --------------------------------------------------------------------------- #
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _support_value(feature: str, frac: float, rng: np.random.Generator) -> float:
|
|
222
|
+
"""An in-support value at ``frac`` of the train [min,max] band for ``feature``."""
|
|
223
|
+
if feature in FEATURE_SUPPORT:
|
|
224
|
+
lo, hi = FEATURE_SUPPORT[feature]
|
|
225
|
+
else:
|
|
226
|
+
lo, hi = 0.0, 1.0
|
|
227
|
+
frac = float(np.clip(frac, 0.02, 0.98))
|
|
228
|
+
return float(lo + frac * (hi - lo))
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def generate_queries(
|
|
232
|
+
cohort: dict,
|
|
233
|
+
n_applicants: int = 300,
|
|
234
|
+
queries_per_applicant: int = _QUERIES_PER_APPLICANT,
|
|
235
|
+
non_intervenable_frac: float = 0.19,
|
|
236
|
+
noop_frac: float = 0.07,
|
|
237
|
+
dose_response_frac: float = 0.20,
|
|
238
|
+
seed: int = config.RANDOM_SEED,
|
|
239
|
+
) -> pd.DataFrame:
|
|
240
|
+
"""Build a query frame mirroring the real Deliverable-C design.
|
|
241
|
+
|
|
242
|
+
Columns: ``query_id, applicant_id, feature_name, intervention_value,
|
|
243
|
+
is_intervenable, is_noop, is_dose_response, dose_rank``. All values are
|
|
244
|
+
in-support. ``~non_intervenable_frac`` of queries target non-intervenable
|
|
245
|
+
features (to exercise SCM propagation incl. ``has_linked_bank_feed`` gating);
|
|
246
|
+
``~noop_frac`` are ``do(X = observed_value)`` no-ops; ``~dose_response_frac``
|
|
247
|
+
of applicants get a monotone dose ladder on a single feature.
|
|
248
|
+
"""
|
|
249
|
+
rng = np.random.Generator(np.random.PCG64(seed))
|
|
250
|
+
features = cohort["features"]
|
|
251
|
+
n_pool = len(features)
|
|
252
|
+
n_applicants = min(n_applicants, n_pool)
|
|
253
|
+
applicant_ids = rng.choice(n_pool, size=n_applicants, replace=False)
|
|
254
|
+
|
|
255
|
+
interv = list(INTERVENABLE_FEATURES)
|
|
256
|
+
non_interv = list(_NON_INTERVENABLE_PROBES)
|
|
257
|
+
n_dose = int(round(dose_response_frac * n_applicants))
|
|
258
|
+
dose_applicants = set(applicant_ids[:n_dose].tolist())
|
|
259
|
+
|
|
260
|
+
rows = []
|
|
261
|
+
qi = 0
|
|
262
|
+
for aid in applicant_ids:
|
|
263
|
+
if aid in dose_applicants:
|
|
264
|
+
# Dose-response ladder: same feature, increasing values, all in-support.
|
|
265
|
+
feat = "aggregate_credit_utilization"
|
|
266
|
+
for rank, frac in enumerate(_DOSE_GRID[:queries_per_applicant]):
|
|
267
|
+
rows.append(
|
|
268
|
+
dict(
|
|
269
|
+
query_id=f"q{qi:05d}",
|
|
270
|
+
applicant_id=int(aid),
|
|
271
|
+
feature_name=feat,
|
|
272
|
+
intervention_value=_support_value(feat, frac, rng),
|
|
273
|
+
is_intervenable=True,
|
|
274
|
+
is_noop=False,
|
|
275
|
+
is_dose_response=True,
|
|
276
|
+
dose_rank=rank,
|
|
277
|
+
)
|
|
278
|
+
)
|
|
279
|
+
qi += 1
|
|
280
|
+
continue
|
|
281
|
+
|
|
282
|
+
for _ in range(queries_per_applicant):
|
|
283
|
+
roll = rng.random()
|
|
284
|
+
is_noop = roll < noop_frac
|
|
285
|
+
target_non_interv = (not is_noop) and (roll < noop_frac + non_intervenable_frac)
|
|
286
|
+
if target_non_interv:
|
|
287
|
+
feat = non_interv[rng.integers(len(non_interv))]
|
|
288
|
+
else:
|
|
289
|
+
feat = interv[rng.integers(len(interv))]
|
|
290
|
+
|
|
291
|
+
if is_noop:
|
|
292
|
+
value = float(np.asarray(features[feat].to_numpy())[aid])
|
|
293
|
+
if not np.isfinite(value):
|
|
294
|
+
# bank-feed-gated NaN for this unit -> fall back to a mid value.
|
|
295
|
+
value = _support_value(feat, 0.5, rng)
|
|
296
|
+
is_noop = False
|
|
297
|
+
else:
|
|
298
|
+
value = _support_value(feat, float(rng.uniform(0.2, 0.85)), rng)
|
|
299
|
+
|
|
300
|
+
rows.append(
|
|
301
|
+
dict(
|
|
302
|
+
query_id=f"q{qi:05d}",
|
|
303
|
+
applicant_id=int(aid),
|
|
304
|
+
feature_name=feat,
|
|
305
|
+
intervention_value=value,
|
|
306
|
+
is_intervenable=feat in INTERVENABLE_FEATURES,
|
|
307
|
+
is_noop=bool(is_noop),
|
|
308
|
+
is_dose_response=False,
|
|
309
|
+
dose_rank=-1,
|
|
310
|
+
)
|
|
311
|
+
)
|
|
312
|
+
qi += 1
|
|
313
|
+
|
|
314
|
+
return pd.DataFrame(rows)
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
# --------------------------------------------------------------------------- #
|
|
318
|
+
# G-computation (standardization) estimator — the deployable causal method
|
|
319
|
+
# --------------------------------------------------------------------------- #
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _topo_order(parents: dict[str, list[str]]) -> list[str]:
|
|
323
|
+
"""Kahn topological order over the DAG nodes (parents -> children)."""
|
|
324
|
+
# Build child adjacency over the union of all referenced nodes.
|
|
325
|
+
nodes: set[str] = set(parents)
|
|
326
|
+
for ps in parents.values():
|
|
327
|
+
nodes.update(ps)
|
|
328
|
+
indeg = {n: 0 for n in nodes}
|
|
329
|
+
children: dict[str, list[str]] = {n: [] for n in nodes}
|
|
330
|
+
for node, ps in parents.items():
|
|
331
|
+
for p in ps:
|
|
332
|
+
children[p].append(node)
|
|
333
|
+
indeg[node] += 1
|
|
334
|
+
# Stable: pop in sorted name order so the result is deterministic.
|
|
335
|
+
ready = sorted(n for n in nodes if indeg[n] == 0)
|
|
336
|
+
order: list[str] = []
|
|
337
|
+
while ready:
|
|
338
|
+
n = ready.pop(0)
|
|
339
|
+
order.append(n)
|
|
340
|
+
for c in sorted(children[n]):
|
|
341
|
+
indeg[c] -= 1
|
|
342
|
+
if indeg[c] == 0:
|
|
343
|
+
ready.append(c)
|
|
344
|
+
ready.sort()
|
|
345
|
+
return order
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
class GComputationEstimator:
|
|
349
|
+
"""G-computation / standardization estimator of ``do(feature = value)``.
|
|
350
|
+
|
|
351
|
+
Learned from OBSERVED (approved) rows ONLY. It uses **no SCM coefficients** —
|
|
352
|
+
only the DAG *topology* (:func:`cldd.scm.dag_parents` /
|
|
353
|
+
:func:`cldd.scm.dag_children`) and observed (feature, default) data. The pieces:
|
|
354
|
+
|
|
355
|
+
* one :class:`~sklearn.ensemble.HistGradientBoostingRegressor` per non-root
|
|
356
|
+
node, fit to predict ``node ~ parents`` (HistGBT handles NaN natively; the
|
|
357
|
+
bank-feed block nodes are fit on feed-linked rows where the block is
|
|
358
|
+
observed);
|
|
359
|
+
* an outcome PD model ``E[default | features]`` reusing
|
|
360
|
+
:func:`cldd.model_pd.train_pd_model`.
|
|
361
|
+
|
|
362
|
+
To answer ``do(X = v)`` for a single applicant row: clamp ``X = v``, walk the
|
|
363
|
+
topological order re-imputing every descendant of ``X`` from the fitted child
|
|
364
|
+
mechanisms (expected value given the updated parents), then score the outcome
|
|
365
|
+
model; the effect is ``post - baseline``. For the structural target
|
|
366
|
+
``has_linked_bank_feed`` it flips the gate and re-imputes the 6-node bank-feed
|
|
367
|
+
block from the fitted child models (mirroring the SCM's information switch),
|
|
368
|
+
rather than overwriting a single column.
|
|
369
|
+
"""
|
|
370
|
+
|
|
371
|
+
def __init__(self, random_state: int = config.RANDOM_SEED):
|
|
372
|
+
self.random_state = int(random_state)
|
|
373
|
+
self.parents = dag_parents()
|
|
374
|
+
self.children = dag_children()
|
|
375
|
+
self.topo = _topo_order(self.parents)
|
|
376
|
+
# Non-root modeled nodes that also appear as columns we can predict.
|
|
377
|
+
self.node_models: dict[str, HistGradientBoostingRegressor] = {}
|
|
378
|
+
self.outcome_model = None
|
|
379
|
+
self.col_index = {c: j for j, c in enumerate(FEATURE_COLUMNS)}
|
|
380
|
+
self._fitted = False
|
|
381
|
+
|
|
382
|
+
# -- which nodes get a fitted child mechanism --------------------------- #
|
|
383
|
+
def _modeled_children(self) -> list[str]:
|
|
384
|
+
"""Non-root nodes that are real feature columns we can fit and impute."""
|
|
385
|
+
out = []
|
|
386
|
+
for node, ps in self.parents.items():
|
|
387
|
+
if not ps:
|
|
388
|
+
continue
|
|
389
|
+
if node not in self.col_index:
|
|
390
|
+
continue
|
|
391
|
+
# Only fit when every parent is also a usable column.
|
|
392
|
+
if all(p in self.col_index for p in ps):
|
|
393
|
+
out.append(node)
|
|
394
|
+
return out
|
|
395
|
+
|
|
396
|
+
def fit(self, features: pd.DataFrame, default: np.ndarray, approved: np.ndarray):
|
|
397
|
+
"""Fit child mechanisms + outcome model on the APPROVED rows only."""
|
|
398
|
+
approved = np.asarray(approved, dtype=bool)
|
|
399
|
+
X_full = features.to_numpy(dtype=float)
|
|
400
|
+
Xa = X_full[approved]
|
|
401
|
+
ya = np.asarray(default, dtype=int)[approved]
|
|
402
|
+
feed_col = self.col_index["has_linked_bank_feed"]
|
|
403
|
+
feed_a = Xa[:, feed_col] > 0.5
|
|
404
|
+
|
|
405
|
+
block = set(BANK_FEED_COLUMNS)
|
|
406
|
+
for node in self._modeled_children():
|
|
407
|
+
ps = self.parents[node]
|
|
408
|
+
pj = [self.col_index[p] for p in ps]
|
|
409
|
+
# Bank-feed block nodes (and any node parented by them) are only
|
|
410
|
+
# observed when a feed is linked — fit on the feed-linked rows so the
|
|
411
|
+
# mechanism is learned where the block exists.
|
|
412
|
+
needs_feed = (node in block) or any(p in block for p in ps)
|
|
413
|
+
rows = feed_a if needs_feed else np.ones(len(Xa), dtype=bool)
|
|
414
|
+
Xp = Xa[rows][:, pj]
|
|
415
|
+
yt = Xa[rows][:, self.col_index[node]]
|
|
416
|
+
ok = np.isfinite(yt)
|
|
417
|
+
if ok.sum() < 20:
|
|
418
|
+
continue
|
|
419
|
+
reg = HistGradientBoostingRegressor(
|
|
420
|
+
random_state=self.random_state,
|
|
421
|
+
max_depth=3,
|
|
422
|
+
max_iter=200,
|
|
423
|
+
learning_rate=0.06,
|
|
424
|
+
l2_regularization=1.0,
|
|
425
|
+
)
|
|
426
|
+
reg.fit(Xp[ok], yt[ok])
|
|
427
|
+
self.node_models[node] = reg
|
|
428
|
+
|
|
429
|
+
self.outcome_model = train_pd_model(
|
|
430
|
+
Xa, ya, random_state=self.random_state
|
|
431
|
+
)
|
|
432
|
+
self._fitted = True
|
|
433
|
+
return self
|
|
434
|
+
|
|
435
|
+
# -- propagate a do() on a batch of applicant rows ---------------------- #
|
|
436
|
+
def _predict_rows(self, X: np.ndarray) -> np.ndarray:
|
|
437
|
+
return predict_pd(self.outcome_model, X)
|
|
438
|
+
|
|
439
|
+
def _impute_descendants(self, X: np.ndarray, changed: set[str]) -> np.ndarray:
|
|
440
|
+
"""Re-impute, in topo order, every node downstream of ``changed`` using the
|
|
441
|
+
fitted child mechanisms. ``X`` is a copy that already has the clamp applied;
|
|
442
|
+
``changed`` is the set of nodes whose value was set/updated."""
|
|
443
|
+
for node in self.topo:
|
|
444
|
+
if node not in self.node_models:
|
|
445
|
+
continue
|
|
446
|
+
ps = self.parents[node]
|
|
447
|
+
if not any(p in changed for p in ps):
|
|
448
|
+
continue
|
|
449
|
+
pj = [self.col_index[p] for p in ps]
|
|
450
|
+
X[:, self.col_index[node]] = self.node_models[node].predict(X[:, pj])
|
|
451
|
+
changed.add(node)
|
|
452
|
+
return X
|
|
453
|
+
|
|
454
|
+
def effect(
|
|
455
|
+
self,
|
|
456
|
+
features: pd.DataFrame,
|
|
457
|
+
feature: str,
|
|
458
|
+
value,
|
|
459
|
+
applicant_ids: np.ndarray,
|
|
460
|
+
) -> np.ndarray:
|
|
461
|
+
"""Estimated per-query effect of ``do(feature = value)`` at each applicant.
|
|
462
|
+
|
|
463
|
+
``value`` is a scalar (broadcast) or per-query array aligned with
|
|
464
|
+
``applicant_ids``. Returns ``post_pd - baseline_pd`` at each queried row.
|
|
465
|
+
"""
|
|
466
|
+
if not self._fitted:
|
|
467
|
+
raise RuntimeError("GComputationEstimator must be fit() before use")
|
|
468
|
+
X_full = features.to_numpy(dtype=float)
|
|
469
|
+
aids = np.asarray(applicant_ids)
|
|
470
|
+
vals = np.broadcast_to(np.asarray(value, dtype=float), (len(aids),))
|
|
471
|
+
|
|
472
|
+
if feature == "has_linked_bank_feed":
|
|
473
|
+
base_rows = X_full[aids].copy()
|
|
474
|
+
baseline = self._predict_rows(base_rows)
|
|
475
|
+
return self._effect_feed_switch(X_full[aids].copy(), vals, baseline)
|
|
476
|
+
|
|
477
|
+
if feature not in self.col_index:
|
|
478
|
+
# Identity / unmodeled non-intervenable node: g-computation also
|
|
479
|
+
# refuses (no fitted mechanism, no column) -> zero effect.
|
|
480
|
+
return np.zeros(len(aids))
|
|
481
|
+
|
|
482
|
+
# Baseline = the deployed model's prediction on the FACTUAL row (observed
|
|
483
|
+
# descendants, no imputation). Post arm = clamp the target, then propagate
|
|
484
|
+
# the change to its descendants through the fitted child mechanisms
|
|
485
|
+
# (standardization / g-formula). The contrast keeps the full descendant
|
|
486
|
+
# contribution (it is NOT cancelled by also imputing the baseline), which
|
|
487
|
+
# is exactly the propagation that naive overwrite-one-column misses.
|
|
488
|
+
col = self.col_index[feature]
|
|
489
|
+
observed = X_full[aids, col].copy()
|
|
490
|
+
base_rows = X_full[aids].copy()
|
|
491
|
+
baseline = self._predict_rows(base_rows)
|
|
492
|
+
|
|
493
|
+
post_rows = X_full[aids].copy()
|
|
494
|
+
post_rows[:, col] = vals
|
|
495
|
+
post_rows = self._impute_descendants(post_rows, changed={feature})
|
|
496
|
+
post = self._predict_rows(post_rows)
|
|
497
|
+
effect = post - baseline
|
|
498
|
+
# Per-unit no-op invariance: where the clamp equals the observed value the
|
|
499
|
+
# intervention is a no-op, so the effect is exactly 0 (no spurious
|
|
500
|
+
# mechanism-reconstruction error leaks in). Matches the SCM's exact no-op.
|
|
501
|
+
is_noop_unit = (vals == observed) | (~np.isfinite(vals) & ~np.isfinite(observed))
|
|
502
|
+
effect[is_noop_unit] = 0.0
|
|
503
|
+
return effect
|
|
504
|
+
|
|
505
|
+
def _effect_feed_switch(
|
|
506
|
+
self, post_rows: np.ndarray, vals: np.ndarray, baseline: np.ndarray
|
|
507
|
+
) -> np.ndarray:
|
|
508
|
+
"""do(has_linked_bank_feed = v): flip the gate and re-impute the block.
|
|
509
|
+
|
|
510
|
+
Mirrors the SCM's structural switch — turning a feed ON reveals the 6-node
|
|
511
|
+
bank-feed block (imputed from the fitted child mechanisms), turning it OFF
|
|
512
|
+
hides it (set the block to NaN, which the NaN-native outcome model accepts).
|
|
513
|
+
Where the feed status does not flip, the row is unchanged (exact no-op).
|
|
514
|
+
"""
|
|
515
|
+
feed_col = self.col_index["has_linked_bank_feed"]
|
|
516
|
+
new_feed = vals > 0.5
|
|
517
|
+
cur_feed = post_rows[:, feed_col] > 0.5
|
|
518
|
+
flips = new_feed != cur_feed
|
|
519
|
+
block_cols = [self.col_index[c] for c in BANK_FEED_COLUMNS]
|
|
520
|
+
# DIAG(leak-fix a): the revenue-derived ratio is gated with the block, so a
|
|
521
|
+
# feed-OFF flip must hide it too (turning feed ON re-imputes it via the
|
|
522
|
+
# observed-revenue child mechanism already, since it is a feed-parented node).
|
|
523
|
+
if "requested_amount_to_observed_revenue" in self.col_index:
|
|
524
|
+
block_cols = block_cols + [self.col_index["requested_amount_to_observed_revenue"]]
|
|
525
|
+
|
|
526
|
+
post_rows[:, feed_col] = new_feed.astype(float)
|
|
527
|
+
# Turn feeds OFF -> hide the block (structural missingness).
|
|
528
|
+
off = flips & (~new_feed)
|
|
529
|
+
if off.any():
|
|
530
|
+
for j in block_cols:
|
|
531
|
+
post_rows[np.ix_(off, [j])] = np.nan
|
|
532
|
+
# Turn feeds ON -> reveal/impute the block from fitted child mechanisms.
|
|
533
|
+
on = flips & new_feed
|
|
534
|
+
if on.any():
|
|
535
|
+
self._impute_descendants(
|
|
536
|
+
post_rows, changed={"has_linked_bank_feed"} | set(BANK_FEED_COLUMNS)
|
|
537
|
+
)
|
|
538
|
+
post = self._predict_rows(post_rows)
|
|
539
|
+
effect = post - baseline
|
|
540
|
+
# Exact no-op for unflipped units (mirror the SCM invariance).
|
|
541
|
+
effect[~flips] = 0.0
|
|
542
|
+
return effect
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
# --------------------------------------------------------------------------- #
|
|
546
|
+
# Reference + naive estimators
|
|
547
|
+
# --------------------------------------------------------------------------- #
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
def _true_counterfactual_effects(
|
|
551
|
+
generator: StructuralBorrowerGenerator,
|
|
552
|
+
state,
|
|
553
|
+
queries: pd.DataFrame,
|
|
554
|
+
) -> tuple[np.ndarray, np.ndarray]:
|
|
555
|
+
"""Per-query (true_effect_at_applicant, true_baseline_at_applicant).
|
|
556
|
+
|
|
557
|
+
The SCM's planted truth: for each query, propagate ``do(feature=value)`` for
|
|
558
|
+
ALL units, then read the effect at the query's own applicant. This is the gold
|
|
559
|
+
standard all estimators are graded against.
|
|
560
|
+
"""
|
|
561
|
+
true_effect = np.zeros(len(queries))
|
|
562
|
+
true_baseline = np.zeros(len(queries))
|
|
563
|
+
aids = queries["applicant_id"].to_numpy()
|
|
564
|
+
for i, (feat, val, aid) in enumerate(
|
|
565
|
+
zip(queries["feature_name"], queries["intervention_value"], aids)
|
|
566
|
+
):
|
|
567
|
+
res = generator.do_intervention(state, feat, float(val))
|
|
568
|
+
true_effect[i] = res.effect[aid]
|
|
569
|
+
true_baseline[i] = res.baseline_pd[aid]
|
|
570
|
+
return true_effect, true_baseline
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
def _naive_observational_effects(
|
|
574
|
+
model,
|
|
575
|
+
features: pd.DataFrame,
|
|
576
|
+
queries: pd.DataFrame,
|
|
577
|
+
) -> np.ndarray:
|
|
578
|
+
"""Naive estimator: condition by overwriting the column, re-predict, subtract.
|
|
579
|
+
|
|
580
|
+
``effect_hat = P_model(Y | X=v, rest=observed_applicant) - P_model(Y |
|
|
581
|
+
observed_applicant)``. No descendant propagation, fit on approved rows only —
|
|
582
|
+
so this is conditioning, not intervening.
|
|
583
|
+
"""
|
|
584
|
+
X = features.to_numpy(dtype=float)
|
|
585
|
+
baseline_pred = predict_pd(model, X) # per-applicant baseline prediction
|
|
586
|
+
col_index = {c: j for j, c in enumerate(FEATURE_COLUMNS)}
|
|
587
|
+
effect = np.zeros(len(queries))
|
|
588
|
+
aids = queries["applicant_id"].to_numpy()
|
|
589
|
+
for i, (feat, val, aid) in enumerate(
|
|
590
|
+
zip(queries["feature_name"], queries["intervention_value"], aids)
|
|
591
|
+
):
|
|
592
|
+
if feat not in col_index:
|
|
593
|
+
effect[i] = 0.0
|
|
594
|
+
continue
|
|
595
|
+
row = X[aid].copy()
|
|
596
|
+
row[col_index[feat]] = float(val)
|
|
597
|
+
post = predict_pd(model, row.reshape(1, -1))[0]
|
|
598
|
+
effect[i] = post - baseline_pred[aid]
|
|
599
|
+
return effect
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
def _oracle_effects(
|
|
603
|
+
generator: StructuralBorrowerGenerator,
|
|
604
|
+
state,
|
|
605
|
+
queries: pd.DataFrame,
|
|
606
|
+
) -> np.ndarray:
|
|
607
|
+
"""TRUTH REFERENCE (NOT an estimator): the SCM's own ``do_intervention``.
|
|
608
|
+
|
|
609
|
+
This calls the exact same structural propagation that *defines* the gold
|
|
610
|
+
standard, so its MAE is ~0 by construction. It is reported only to mark the
|
|
611
|
+
ceiling the deployable g-computation estimator is reaching toward — it is never
|
|
612
|
+
presented as a method that "recovers" an unknown truth.
|
|
613
|
+
"""
|
|
614
|
+
effect = np.zeros(len(queries))
|
|
615
|
+
aids = queries["applicant_id"].to_numpy()
|
|
616
|
+
for i, (feat, val, aid) in enumerate(
|
|
617
|
+
zip(queries["feature_name"], queries["intervention_value"], aids)
|
|
618
|
+
):
|
|
619
|
+
res = generator.do_intervention(state, feat, float(val))
|
|
620
|
+
effect[i] = res.effect[aid]
|
|
621
|
+
return effect
|
|
622
|
+
|
|
623
|
+
|
|
624
|
+
# --------------------------------------------------------------------------- #
|
|
625
|
+
# Entry point
|
|
626
|
+
# --------------------------------------------------------------------------- #
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
def run_counterfactual_eval(
|
|
630
|
+
n_applicants: int = config.DEFAULT_N_APPLICANTS,
|
|
631
|
+
selection_severity: float = 1.0,
|
|
632
|
+
n_query_applicants: int = 300,
|
|
633
|
+
seed: int = config.RANDOM_SEED,
|
|
634
|
+
generator: StructuralBorrowerGenerator | None = None,
|
|
635
|
+
) -> CounterfactualResult:
|
|
636
|
+
"""Build a cohort, generate Deliverable-C-style queries, grade the estimators.
|
|
637
|
+
|
|
638
|
+
Three-way comparison against the SCM's planted truth:
|
|
639
|
+
|
|
640
|
+
* **naive** observational conditioning,
|
|
641
|
+
* **gcomp** g-computation / standardization (the deployable causal estimator),
|
|
642
|
+
* **oracle** = the SCM's own ``do_intervention`` kept ONLY as a truth reference.
|
|
643
|
+
|
|
644
|
+
Both deployable estimators are fit on the APPROVED rows only (selective-labels
|
|
645
|
+
regime), so their conditionals are distorted by selection — exactly the
|
|
646
|
+
confound g-computation's backdoor adjustment is meant to repair.
|
|
647
|
+
"""
|
|
648
|
+
if generator is None:
|
|
649
|
+
generator = StructuralBorrowerGenerator(
|
|
650
|
+
n_applicants=n_applicants,
|
|
651
|
+
selection_severity=selection_severity,
|
|
652
|
+
seed=seed,
|
|
653
|
+
)
|
|
654
|
+
cohort = generator.generate_cohort()
|
|
655
|
+
state = cohort["scm_state"]
|
|
656
|
+
features = cohort["features"]
|
|
657
|
+
approved = cohort["approved"]
|
|
658
|
+
true_default = cohort["true_default"]
|
|
659
|
+
|
|
660
|
+
# --- fit the naive observational PD model on APPROVED rows (selective labels) ---
|
|
661
|
+
X = features.to_numpy(dtype=float)
|
|
662
|
+
train_rs = seed + config.TRAIN_SEED_OFFSET
|
|
663
|
+
model = train_pd_model(X[approved], true_default[approved], random_state=train_rs)
|
|
664
|
+
|
|
665
|
+
# --- fit the deployable g-computation estimator on APPROVED rows ---
|
|
666
|
+
gcomp = GComputationEstimator(random_state=train_rs).fit(
|
|
667
|
+
features, true_default, approved
|
|
668
|
+
)
|
|
669
|
+
|
|
670
|
+
# --- queries ---
|
|
671
|
+
queries = generate_queries(
|
|
672
|
+
cohort, n_applicants=n_query_applicants, seed=seed
|
|
673
|
+
).reset_index(drop=True)
|
|
674
|
+
|
|
675
|
+
# --- true counterfactual + estimators (all measured as EFFECTS) ---
|
|
676
|
+
true_effect, true_baseline = _true_counterfactual_effects(generator, state, queries)
|
|
677
|
+
naive_effect = _naive_observational_effects(model, features, queries)
|
|
678
|
+
oracle_effect = _oracle_effects(generator, state, queries)
|
|
679
|
+
|
|
680
|
+
# g-computation, grouped by (feature, value) so each do() is a single vector op.
|
|
681
|
+
aids_all = queries["applicant_id"].to_numpy()
|
|
682
|
+
gcomp_effect = np.zeros(len(queries))
|
|
683
|
+
for (feat, val), grp in queries.groupby(
|
|
684
|
+
["feature_name", "intervention_value"], sort=False
|
|
685
|
+
):
|
|
686
|
+
idx = grp.index.to_numpy()
|
|
687
|
+
gcomp_effect[idx] = gcomp.effect(features, feat, float(val), aids_all[idx])
|
|
688
|
+
|
|
689
|
+
queries = queries.assign(
|
|
690
|
+
true_baseline_pd=true_baseline,
|
|
691
|
+
true_effect=true_effect,
|
|
692
|
+
naive_effect=naive_effect,
|
|
693
|
+
gcomp_effect=gcomp_effect,
|
|
694
|
+
oracle_effect=oracle_effect,
|
|
695
|
+
naive_abs_err=np.abs(naive_effect - true_effect),
|
|
696
|
+
gcomp_abs_err=np.abs(gcomp_effect - true_effect),
|
|
697
|
+
oracle_abs_err=np.abs(oracle_effect - true_effect),
|
|
698
|
+
)
|
|
699
|
+
|
|
700
|
+
is_interv = queries["is_intervenable"].to_numpy(dtype=bool)
|
|
701
|
+
non_interv = ~is_interv
|
|
702
|
+
|
|
703
|
+
def _mae(err, mask=None):
|
|
704
|
+
e = err if mask is None else err[mask]
|
|
705
|
+
return float(np.mean(e)) if len(e) else float("nan")
|
|
706
|
+
|
|
707
|
+
def _bias(est, truth, mask=None):
|
|
708
|
+
d = (est - truth) if mask is None else (est - truth)[mask]
|
|
709
|
+
return float(np.mean(d)) if len(d) else float("nan")
|
|
710
|
+
|
|
711
|
+
naive_err = queries["naive_abs_err"].to_numpy()
|
|
712
|
+
gcomp_err = queries["gcomp_abs_err"].to_numpy()
|
|
713
|
+
oracle_err = queries["oracle_abs_err"].to_numpy()
|
|
714
|
+
|
|
715
|
+
# Propagation targets: features with SCM descendants OR non-intervenable —
|
|
716
|
+
# the set where naive conditioning structurally cannot recover the intervention
|
|
717
|
+
# (includes has_linked_bank_feed via the non-intervenable mask).
|
|
718
|
+
prop_mask = (
|
|
719
|
+
queries["feature_name"].isin(_PROPAGATION_FEATURES).to_numpy() | non_interv
|
|
720
|
+
)
|
|
721
|
+
|
|
722
|
+
# Strong descendant-propagation slice: intervenable features with >=2 SCM
|
|
723
|
+
# descendants. This is the cleanest, most stable case where naive
|
|
724
|
+
# overwrite-one-column is structurally wrong (the moved cause has several
|
|
725
|
+
# risk-bearing children it fails to update) and g-computation propagates them.
|
|
726
|
+
strong_mask = queries["feature_name"].isin(_MULTI_DESCENDANT_FEATURES).to_numpy()
|
|
727
|
+
|
|
728
|
+
naive_mae_non = _mae(naive_err, non_interv)
|
|
729
|
+
gcomp_mae_non = _mae(gcomp_err, non_interv)
|
|
730
|
+
naive_mae_prop = _mae(naive_err, prop_mask)
|
|
731
|
+
gcomp_mae_prop = _mae(gcomp_err, prop_mask)
|
|
732
|
+
naive_mae_strong = _mae(naive_err, strong_mask)
|
|
733
|
+
gcomp_mae_strong = _mae(gcomp_err, strong_mask)
|
|
734
|
+
naive_mae_all = _mae(naive_err)
|
|
735
|
+
gcomp_mae_all = _mae(gcomp_err)
|
|
736
|
+
|
|
737
|
+
return CounterfactualResult(
|
|
738
|
+
queries=queries,
|
|
739
|
+
naive_mae=naive_mae_all,
|
|
740
|
+
naive_bias=_bias(naive_effect, true_effect),
|
|
741
|
+
naive_mae_intervenable=_mae(naive_err, is_interv),
|
|
742
|
+
naive_mae_non_intervenable=naive_mae_non,
|
|
743
|
+
naive_mae_propagation=naive_mae_prop,
|
|
744
|
+
gcomp_mae=gcomp_mae_all,
|
|
745
|
+
gcomp_bias=_bias(gcomp_effect, true_effect),
|
|
746
|
+
gcomp_mae_intervenable=_mae(gcomp_err, is_interv),
|
|
747
|
+
gcomp_mae_non_intervenable=gcomp_mae_non,
|
|
748
|
+
gcomp_mae_propagation=gcomp_mae_prop,
|
|
749
|
+
oracle_mae=_mae(oracle_err),
|
|
750
|
+
oracle_mae_propagation=_mae(oracle_err, prop_mask),
|
|
751
|
+
naive_mae_strong_propagation=naive_mae_strong,
|
|
752
|
+
gcomp_mae_strong_propagation=gcomp_mae_strong,
|
|
753
|
+
mae_gap_overall=naive_mae_all - gcomp_mae_all,
|
|
754
|
+
mae_gap_non_intervenable=naive_mae_non - gcomp_mae_non,
|
|
755
|
+
mae_gap_propagation=naive_mae_prop - gcomp_mae_prop,
|
|
756
|
+
mae_gap_strong_propagation=naive_mae_strong - gcomp_mae_strong,
|
|
757
|
+
n_queries=len(queries),
|
|
758
|
+
n_noop=int(queries["is_noop"].sum()),
|
|
759
|
+
n_non_intervenable=int(non_interv.sum()),
|
|
760
|
+
n_strong_propagation=int(strong_mask.sum()),
|
|
761
|
+
meta={
|
|
762
|
+
"selection_severity": selection_severity,
|
|
763
|
+
"n_applicants": n_applicants,
|
|
764
|
+
"seed": seed,
|
|
765
|
+
"approval_rate": float(approved.mean()),
|
|
766
|
+
},
|
|
767
|
+
)
|