Skip to content

Commit 9447efd

Browse files
committed
Add subgroup CATE estimation (econml DML) to lending audit
LendingFairnessAudit.estimate_cate() wraps econml.dml.CausalForestDML to estimate the conditional average treatment effect of the protected attribute on approval (as in P1). The treatment defaults to group != reference and the outcome to the approval labels; nuisance models default to random forests. Causal validity is conditional on the DML assumptions, stated in the docstring. econml is an optional dependency (pip install fairscope[lending]); a guarded import raises an informative ImportError naming the extra. A planted-effect test (importorskip) confirms the estimator recovers the right sign on the strong stratum.
1 parent 9367a9b commit 9447efd

2 files changed

Lines changed: 102 additions & 0 deletions

File tree

fairscope/lending/audit.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,64 @@ def from_outcomes(cls, approved, group, year, *, reference, alpha=0.05):
3939
"""Build an audit from precomputed approval outcomes."""
4040
return cls(approved=approved, group=group, year=year, reference=reference, alpha=alpha)
4141

42+
def _binary_treatment(self):
43+
return (self.group != self.reference).astype(int)
44+
45+
def estimate_cate(
46+
self,
47+
X,
48+
*,
49+
treatment=None,
50+
outcome=None,
51+
model_y=None,
52+
model_t=None,
53+
n_estimators=500,
54+
random_state=0,
55+
):
56+
"""Per-subgroup conditional average treatment effect (CATE) of the protected
57+
attribute on approval, via Causal Forest DML (``econml.dml.CausalForestDML``,
58+
as in P1).
59+
60+
The CAUSAL CLAIM IS CONDITIONAL on the DML assumptions (unconfoundedness given
61+
the supplied features ``X``, and overlap). This estimates an effect under those
62+
assumptions; it does not, on its own, prove discrimination.
63+
64+
Parameters
65+
----------
66+
X : array (n, k) of heterogeneity features.
67+
treatment : binary array; defaults to ``group != reference``.
68+
outcome : binary array; defaults to the ``approved`` outcomes.
69+
model_y, model_t : nuisance estimators; default to random forests.
70+
71+
Returns ``{"ate", "effect", "effect_interval"}``. Requires the optional
72+
dependency: ``pip install fairscope[lending]``.
73+
"""
74+
try:
75+
from econml.dml import CausalForestDML
76+
except ImportError as exc: # optional dependency
77+
raise ImportError(
78+
"Subgroup CATE requires the optional dependency: " "pip install fairscope[lending]"
79+
) from exc
80+
from sklearn.ensemble import RandomForestClassifier, RandomForestRegressor
81+
82+
T = self._binary_treatment() if treatment is None else np.asarray(treatment)
83+
Y = self.approved if outcome is None else np.asarray(outcome)
84+
X = np.asarray(X)
85+
est = CausalForestDML(
86+
model_y=model_y or RandomForestRegressor(random_state=random_state),
87+
model_t=model_t or RandomForestClassifier(random_state=random_state),
88+
discrete_treatment=True,
89+
n_estimators=n_estimators,
90+
random_state=random_state,
91+
)
92+
est.fit(Y, T, X=X)
93+
lo, hi = est.effect_interval(X, alpha=self.alpha)
94+
return {
95+
"ate": float(est.ate(X)),
96+
"effect": est.effect(X),
97+
"effect_interval": (lo, hi),
98+
}
99+
42100
def run(self) -> LendingReport:
43101
rows = []
44102
for yr in sorted(np.unique(self.year).tolist()):

tests/test_lending.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import numpy as np
22
import pandas as pd
3+
import pytest
34

45
from fairscope.lending import LendingFairnessAudit, LendingReport
56

@@ -52,3 +53,46 @@ def test_summary_returns_text():
5253
.summary()
5354
)
5455
assert isinstance(text, str) and "minority" in text
56+
57+
58+
def test_estimate_cate_missing_econml_raises(monkeypatch):
59+
import builtins
60+
61+
real = builtins.__import__
62+
63+
def _no_econml(name, *a, **k):
64+
if name.startswith("econml"):
65+
raise ImportError("no econml")
66+
return real(name, *a, **k)
67+
68+
monkeypatch.setattr(builtins, "__import__", _no_econml)
69+
rng = np.random.default_rng(2)
70+
n = 200
71+
audit = LendingFairnessAudit.from_outcomes(
72+
rng.integers(0, 2, n),
73+
np.where(rng.random(n) < 0.5, "minority", "reference"),
74+
np.full(n, 2021),
75+
reference="reference",
76+
)
77+
with pytest.raises(ImportError, match=r"fairscope\[lending\]"):
78+
audit.estimate_cate(np.zeros((n, 2)))
79+
80+
81+
def test_estimate_cate_recovers_planted_effect():
82+
pytest.importorskip("econml")
83+
rng = np.random.default_rng(3)
84+
n = 2000
85+
X = rng.normal(size=(n, 3)) # heterogeneity covariates
86+
T = (rng.random(n) < 0.5).astype(int) # protected indicator (treatment)
87+
tau = 0.8 * (X[:, 0] > 0) # heterogeneous planted effect on the X0>0 stratum
88+
Y = (0.3 + 0.5 * X[:, 0] + tau * T + rng.normal(scale=0.3, size=n) > 0.5).astype(int)
89+
audit = LendingFairnessAudit.from_outcomes(
90+
Y, np.where(T == 1, "minority", "reference"), np.full(n, 2021), reference="reference"
91+
)
92+
# defaults: treatment derives from group != reference, outcome from approved (P1 style)
93+
res = audit.estimate_cate(X, n_estimators=200)
94+
assert res["ate"] != 0
95+
lo, hi = res["effect_interval"]
96+
assert len(lo) == len(hi) == n
97+
strong = X[:, 0] > 0
98+
assert np.median(res["effect"][strong]) > 0 # right sign on the strong stratum

0 commit comments

Comments
 (0)