Credit-risk tutorial¶
The full workflow: mine candidate constraints from data, review and accept them, price a missing-value flip, build a rating ladder, and generate diverse alternatives.
import numpy as np
rng = np.random.default_rng(42)
n = 6000
names = [
"income_monthly", "utilization", "n_active_loans", "n_loans_total",
"max_dpd_30d", "max_dpd_12m", "months_since_last_delinq", "age",
]
income = rng.lognormal(8.3, 0.5, n).round(-1)
utilization = rng.beta(2, 3, n).round(3)
n_total = rng.poisson(4, n).astype(float) + 1
n_active = np.minimum(np.floor(n_total * rng.beta(3, 2, n)), n_total)
dpd_12m = np.floor(rng.exponential(6, n)) * (rng.random(n) < 0.4)
dpd_30d = np.floor(dpd_12m * rng.beta(2, 4, n))
months_delinq = rng.exponential(14, n).round(0)
months_delinq[dpd_12m == 0] = np.nan # no delinquency -> no record
age = rng.integers(21, 75, n).astype(float)
X = np.column_stack([income, utilization, n_active, n_total,
dpd_30d, dpd_12m, months_delinq, age])
risk = (
-0.9 * np.log(income / 4000)
+ 2.2 * utilization
+ 0.35 * dpd_30d + 0.15 * dpd_12m
- 0.02 * np.nan_to_num(months_delinq, nan=36.0)
- 0.015 * age
)
y = (risk + rng.logistic(scale=0.8, size=n) > np.median(risk)).astype(int)
X.shape, y.mean().round(3)
((6000, 8), np.float64(0.534))
import xgboost as xgb
model = xgb.XGBClassifier(n_estimators=120, max_depth=4, learning_rate=0.2,
random_state=0)
model.fit(X, y)
model.get_booster().feature_names = list(names) # domain names for explanations
proba = model.predict_proba(X)[:, 1]
cutoff = 0.30 # the credit policy's PD cutoff
applicant = X[int(np.argmax(proba))] # a clearly declined applicant
float(model.predict_proba(applicant.reshape(1, -1))[0, 1])
0.9998914003372192
1. Mine candidate constraints¶
Invariants are mined from the sample and returned for review — nothing is auto-applied. Note the DPD hierarchy and loan-count order arriving as ready-to-paste code.
import treecf
mined = treecf.suggest_constraints(X, feature_names=names)
for s in mined[:8]:
print(s.as_code())
constraint("n_active_loans <= n_loans_total") # support=1.0000, n=6000
constraint("max_dpd_30d <= max_dpd_12m") # support=1.0000, n=6000
constraint("max_dpd_12m <= income_monthly") # support=1.0000, n=6000
constraint("months_since_last_delinq <= income_monthly") # support=1.0000, n=2044
constraint("age <= income_monthly") # support=1.0000, n=6000
constraint("utilization <= n_loans_total") # support=1.0000, n=6000
constraint("n_loans_total <= age") # support=1.0000, n=6000
constraint("max_dpd_30d <= age") # support=1.0000, n=6000
accepted = [s.constraint for s in mined
if s.kind == "order" and s.support == 1.0]
accepted
[Linear(coefficients={'n_active_loans': 1.0, 'n_loans_total': -1.0}, op='<=', rhs=0.0, missing_policy='satisfied'),
Linear(coefficients={'max_dpd_30d': 1.0, 'max_dpd_12m': -1.0}, op='<=', rhs=0.0, missing_policy='satisfied'),
Linear(coefficients={'max_dpd_12m': 1.0, 'income_monthly': -1.0}, op='<=', rhs=0.0, missing_policy='satisfied'),
Linear(coefficients={'months_since_last_delinq': 1.0, 'income_monthly': -1.0}, op='<=', rhs=0.0, missing_policy='satisfied'),
Linear(coefficients={'age': 1.0, 'income_monthly': -1.0}, op='<=', rhs=0.0, missing_policy='satisfied'),
Linear(coefficients={'utilization': 1.0, 'n_loans_total': -1.0}, op='<=', rhs=0.0, missing_policy='satisfied'),
Linear(coefficients={'n_loans_total': 1.0, 'age': -1.0}, op='<=', rhs=0.0, missing_policy='satisfied'),
Linear(coefficients={'max_dpd_30d': 1.0, 'age': -1.0}, op='<=', rhs=0.0, missing_policy='satisfied')]
2. Explain with a priced missing-value flip¶
months_since_last_delinq is NaN when there is no delinquency record — and reaching that state is a legitimate recommendation with an explicit price (delta_miss).
from treecf import AllowMissing, Explainer, Freeze, Target
exp = Explainer(
model,
background=X,
constraints=accepted + [
Freeze("age"),
AllowMissing("months_since_last_delinq", delta_miss=2.0),
],
value_policy={"max_dpd_30d": "integer", "max_dpd_12m": "integer",
"n_active_loans": "integer", "n_loans_total": "integer"},
)
res = exp.explain(applicant, target=Target.probability(range=(0.0, cutoff)),
seed=0)
res.changes
{'income_monthly': (1640.0, 1902.739347036747),
'utilization': (0.186, 0.1419999897480011),
'n_loans_total': (2.0, 1.0),
'max_dpd_30d': (15.0, 3.0),
'max_dpd_12m': (24.0, 3.0),
'months_since_last_delinq': (26.0, nan)}
3. The rating ladder¶
One search per band: the increasing cost of each better grade.
ladder = exp.explain(applicant, seed=0, target=Target.bands({
"approve": (0.00, 0.30),
"prime": (0.00, 0.15),
"super": (0.00, 0.05),
}))
{k: (round(v.distance, 3) if hasattr(v, 'distance') else v.reason)
for k, v in ladder.items()}
{'approve': 12.805, 'prime': 12.822, 'super': 15.293}
from treecf.viz import plot_ladder
plot_ladder(ladder);
4. Alternative plans, and which levers are essential¶
Block each of the primary plan's biggest levers in turn and re-solve. Levers with a workaround yield an alternative plan (at a higher cost); levers with none are essential — approval is unreachable without them. Both answers are useful to an applicant.
from treecf import Infeasible
base = accepted + [Freeze("age"),
AllowMissing("months_since_last_delinq", delta_miss=2.0)]
policy = {"max_dpd_30d": "integer", "max_dpd_12m": "integer",
"n_active_loans": "integer", "n_loans_total": "integer"}
idx = {f: i for i, f in enumerate(names)}
levers = sorted(res.changes,
key=lambda f: abs(res.changes[f][1] - res.changes[f][0])
/ exp.sigma[idx[f]], reverse=True)[:3]
alternatives, essential = [res], []
for lever in levers:
alt = Explainer(model, background=X, value_policy=policy,
constraints=base + [Freeze(lever)])
cand = alt.explain(applicant, seed=0, time_budget_s=30,
target=Target.probability(range=(0.0, cutoff)))
if isinstance(cand, Infeasible):
essential.append(lever) # no plan exists without this lever
else:
alternatives.append(cand)
print("essential levers:", essential)
[(round(a.distance, 2), sorted(a.changes)) for a in alternatives]
essential levers: ['max_dpd_12m', 'max_dpd_30d']
[(12.8, ['income_monthly', 'max_dpd_12m', 'max_dpd_30d', 'months_since_last_delinq', 'n_loans_total', 'utilization']), (13.21, ['income_monthly', 'max_dpd_12m', 'max_dpd_30d', 'months_since_last_delinq', 'n_active_loans', 'utilization'])]
from treecf.viz import plot_counterfactuals
plot_counterfactuals(alternatives);
Compare the plans directly: every alternative's changes on shared feature axes (one color per plan, gray dots mark the factual values), and the cost-vs-outcome trade-off — what each plan asks for and what it buys.
from treecf.viz import plot_alternatives, plot_tradeoff
plot_alternatives(alternatives, explainer=exp); # deltas in sigma units
plot_tradeoff(alternatives, target=Target.probability(range=(0.0, cutoff)));
5. Mass-producing counterfactuals for a day's declines¶
Score a day's applications, take the declines, and produce (up to) two recourse plans per applicant in one call — then store the batch and look plans up later without recomputing.
import time
declined = np.flatnonzero(proba > cutoff)[:200] # today's declines
app_ids = [f"APP-{i:05d}" for i in declined]
start = time.perf_counter()
batch = exp.explain_batch(
X[declined],
target=Target.probability(range=(0.0, cutoff)),
n_per_example=2, # counterfactuals per applicant
diversity="seeds",
ids=app_ids,
seed=0,
)
wall = time.perf_counter() - start
feasible = sum(r.feasible for r in batch)
print(f"{len(batch)} plans for {len(declined)} applicants "
f"in {wall:.1f}s ({1000 * wall / len(declined):.0f} ms/applicant)")
print(f"feasible plans: {feasible}")
269 plans for 200 applicants in 3.0s (15 ms/applicant) feasible plans: 269
import pathlib, tempfile
store_path = pathlib.Path(tempfile.mkdtemp()) / "counterfactuals_today.json"
batch.save(store_path) # store once...
from treecf import BatchResult
stored = BatchResult.load(store_path)
stored.for_id(app_ids[0]) # ...look up any time
[BatchRecord(id='APP-00000', k=0, feasible=True, x_cf=array([4.69e+03, 3.13e-01, 4.00e+00, 8.00e+00, 0.00e+00, 0.00e+00,
5.40e+01, 2.90e+01]), changes={'months_since_last_delinq': (nan, 54.0)}, distance=0.2857142857142857, n_changed=1, score_raw=-0.9703074229268243, score_prob=0.27481923054079793, seed=0, blocked_lever=None, coalition=None, region=None, proof='heuristic', solver_stats={}, calibrator_fingerprint=None, score_calibrated=None)]
stored.to_frame().head(6) # or analyze the whole day as a DataFrame
| id | k | feasible | distance | n_changed | score_raw | score_prob | seed | blocked_lever | coalition | proof | changed_features | cf_income_monthly | cf_utilization | cf_n_active_loans | cf_n_loans_total | cf_max_dpd_30d | cf_max_dpd_12m | cf_months_since_last_delinq | cf_age | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | APP-00000 | 0 | True | 0.285714 | 1 | -0.970307 | 0.274819 | 0 | None | None | heuristic | [months_since_last_delinq] | 4690.0 | 0.313 | 4.0 | 8.0 | 0.0 | 0.0 | 54.0 | 29.0 |
| 1 | APP-00001 | 0 | True | 0.285714 | 1 | -1.356183 | 0.204861 | 1009 | None | None | heuristic | [months_since_last_delinq] | 2390.0 | 0.222 | 8.0 | 9.0 | 0.0 | 0.0 | 54.0 | 36.0 |
| 2 | APP-00002 | 0 | True | 0.285714 | 1 | -1.235083 | 0.225293 | 2018 | None | None | heuristic | [months_since_last_delinq] | 5860.0 | 0.072 | 8.0 | 10.0 | 0.0 | 0.0 | 61.0 | 39.0 |
| 3 | APP-00002 | 1 | True | 1.174497 | 2 | -1.052479 | 0.258749 | 2019 | None | None | heuristic | [n_active_loans, utilization] | 5860.0 | 0.046 | 7.0 | 10.0 | 0.0 | 0.0 | NaN | 39.0 |
| 4 | APP-00003 | 0 | True | 1.000000 | 1 | -1.320359 | 0.210759 | 3027 | None | None | heuristic | [n_active_loans] | 6440.0 | 0.142 | 7.0 | 12.0 | 0.0 | 0.0 | NaN | 54.0 |
| 5 | APP-00004 | 0 | True | 0.547253 | 2 | -1.202409 | 0.231047 | 4036 | None | None | heuristic | [income_monthly, months_since_last_delinq] | 1860.0 | 0.579 | 3.0 | 4.0 | 0.0 | 0.0 | 61.0 | 28.0 |
Visualizing the batch¶
Four batch-level views: which levers the plans use (and in which direction), the effort each change costs per plan, cost/sparsity/feasibility at a glance, and how far each lever actually moves across applicants.
from treecf.viz_batch import plot_batch_levers, plot_batch_matrix
plot_batch_levers(batch);
plot_batch_matrix(batch, explainer=exp, max_row_labels=15);
from treecf.viz_batch import plot_batch_deltas, plot_batch_summary
plot_batch_summary(batch);
plot_batch_deltas(batch, explainer=exp); # deltas in sigma units
6. Coalitions: grouped recourse¶
A single plan can mix unrelated levers — income, credit usage, and debt history in one instruction. Coalitions split recourse by what the applicant controls together: one counterfactual per named feature group, each allowed to change only its own group. An infeasible group is a finding in itself: that front alone cannot reach the target. The "(all levers)" baseline shows what the grouping costs versus the unrestricted optimum. Opt-in — plain explain is unchanged.
groups = {
"debt history": ["max_dpd_30d", "max_dpd_12m", "months_since_last_delinq"],
"credit usage": ["utilization", "n_active_loans", "n_loans_total"],
"income": ["income_monthly"],
}
grouped = exp.explain_coalitions(
applicant, target=Target.probability(range=(0.0, cutoff)),
coalitions=groups, include_full=True, seed=0,
)
{name: (round(out.distance, 2) if hasattr(out, 'distance') else 'infeasible')
for name, out in grouped.items()}
{'(all levers)': 12.8,
'debt history': 'infeasible',
'credit usage': 'infeasible',
'income': 'infeasible'}
plot_alternatives(grouped, explainer=exp); # coalition names label the plans
plot_tradeoff(grouped, target=Target.probability(range=(0.0, cutoff)));
The x-position above is the real model output, so the boundary in this plot is the actual target band, not an illustration.
import matplotlib.pyplot as plt
from treecf.viz import plot_recourse_map
fig, axes = plt.subplots(1, 2, figsize=(13, 4.5))
plot_recourse_map(exp, applicant, grouped,
target=Target.probability(range=(0.0, cutoff)), ax=axes[0])
plot_recourse_map(exp, applicant, grouped,
target=Target.probability(range=(0.0, cutoff)), ax=axes[1],
schematic=True)
fig.tight_layout();
The same mode scales to the whole batch: one record per coalition per applicant, with the group name in the coalition column.
grouped_batch = exp.explain_batch(
X[declined][:20], target=Target.probability(range=(0.0, cutoff)),
diversity="coalitions", coalitions=groups, include_full=True,
ids=app_ids[:20], seed=0,
)
grouped_batch.to_frame()[
["id", "k", "coalition", "feasible", "distance", "n_changed"]
].head(8)
| id | k | coalition | feasible | distance | n_changed | |
|---|---|---|---|---|---|---|
| 0 | APP-00000 | 0 | (all levers) | True | 0.285714 | 1.0 |
| 1 | APP-00000 | 1 | debt history | True | 0.285714 | 1.0 |
| 2 | APP-00000 | 2 | credit usage | True | 0.758389 | 1.0 |
| 3 | APP-00000 | 3 | income | True | 1.038462 | 1.0 |
| 4 | APP-00001 | 0 | (all levers) | True | 0.285714 | 1.0 |
| 5 | APP-00001 | 1 | debt history | True | 0.285714 | 1.0 |
| 6 | APP-00001 | 2 | credit usage | True | 2.046980 | 2.0 |
| 7 | APP-00001 | 3 | income | True | 2.230769 | 1.0 |
7. Exact backend: proofs, certified infeasibility, and regions¶
Everything above is backend="genetic" (the default): fast, feasibility-first, and honest that it never claims optimality (proof="heuristic"). backend="exact" runs a branch-and-bound search over the same cell grid instead and proves what it finds — at the cost of a slower solve. Proof is not free: even this small model can exhaust the search's default node budget when every feature is free to move, so below we restrict the search to the levers that matter with Freeze, the same constraint the genetic search already understands. Certification in the docs has the full proof taxonomy and its honesty notes.
from treecf import constraint
influential = {"income_monthly", "utilization",
"max_dpd_30d", "max_dpd_12m"}
exact_exp = Explainer(
model, background=X,
constraints=[Freeze(f) for f in names if f not in influential]
+ [constraint("max_dpd_30d <= max_dpd_12m")],
)
exact_res = exact_exp.explain(
applicant, target=Target.probability(range=(0.0, cutoff)),
backend="exact", seed=0,
)
exact_res.proof, exact_res.solver_stats["nodes_expanded"], exact_res.changes
('optimal',
513755,
{'income_monthly': (1640.0, 2680.0),
'utilization': (0.186, 0.1419999897480011),
'max_dpd_30d': (15.0, 1.9999998807907104),
'max_dpd_12m': (24.0, 3.999999761581421)})
proof="optimal": no cheaper feasible row exists among these four levers, not just the one the search happened to find. Ask for the impossible and the same backend proves that too — freeze every feature and there is nothing left to move at all.
frozen_exp = Explainer(model, background=X, constraints=[Freeze(f) for f in names])
impossible = frozen_exp.explain(
applicant, target=Target.probability(range=(0.0, cutoff)),
backend="exact", seed=0,
)
impossible.proof, impossible.reason
('certified',
'no counterfactual exists in the target interval under the given constraints (certified; 5 nodes)')
Certified regions¶
region=True widens a verified counterfactual into a box: every point inside it, not just the returned row, is independently still a valid plan. describe() phrases each covered feature — two-sided when both edges are finite, one-sided only when the other side is genuinely unconstrained, not merely wide.
region_res = exact_exp.explain(
applicant, target=Target.probability(range=(0.0, cutoff)),
backend="exact", region=True, seed=0,
)
region_res.region.describe()
{'income_monthly': 'in [2.68e+03, 2.7e+03]',
'utilization': 'in [0.14, 0.142]',
'max_dpd_30d': '≤ 2',
'max_dpd_12m': 'in [3, 4]'}
from treecf.viz import plot_recourse_map
plot_recourse_map(exact_exp, applicant, region_res,
target=Target.probability(range=(0.0, cutoff)), schematic=True);