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