Quickstart¶
Train an XGBoost credit model on synthetic data, then ask treecf the core question: what is the minimal, feasible change that gets this applicant under the approval cutoff? The search runs on treecf's bundled Rust engine and every answer is float-verified against the model before it is returned.
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
Build the explainer¶
The background sample fits robust per-feature distance normalizers (MAD chain). Two domain constraints: age is immutable here, and the 30-day DPD can never exceed the 12-month DPD.
from treecf import Explainer, Freeze, Target, constraint
exp = Explainer(
model,
background=X,
constraints=[
Freeze("age"),
constraint("max_dpd_30d <= max_dpd_12m"),
constraint("n_active_loans <= n_loans_total"),
],
weights={"income_monthly": 2.0}, # income is hard to change
)
res = exp.explain(applicant, target=Target.probability(range=(0.0, cutoff)),
seed=0)
res.proof, res.n_changed, round(res.score_prob, 4)
('heuristic', 4, 0.2949)
res.changes
{'income_monthly': (1640.0, 2440.0),
'utilization': (0.186, 0.04599999636411667),
'max_dpd_30d': (15.0, 2.999999761581421),
'max_dpd_12m': (24.0, 2.999999761581421)}
Visualize the recommendation¶
from treecf.viz import plot_changes
plot_changes(res);
Waterfall (SHAP-style): each bar is the exact probability delta from one change, applied largest-first; the red line is the policy cutoff. Effort shows where the applicant's work goes instead.
from treecf.viz import plot_effort, plot_waterfall
plot_waterfall(exp, res, target=Target.probability(range=(0.0, cutoff)));
plot_effort(exp, res);
Compare alternative plans¶
One plan is rarely the whole story. diversity="lever-blocking" re-solves with each plan's biggest lever frozen, producing structurally distinct alternatives. Compare them: every plan's changes on shared axes (standardized to Ī/Ļ, one color per plan), and what each plan costs against what it buys.
batch = exp.explain_batch(
applicant.reshape(1, -1),
target=Target.probability(range=(0.0, cutoff)),
n_per_example=3,
diversity="lever-blocking",
seed=0,
)
plans = batch.for_id(0)
[(round(p.distance, 2), p.blocked_lever, sorted(p.changes)) for p in plans]
[(13.19, None, ['income_monthly', 'max_dpd_12m', 'max_dpd_30d', 'utilization']), (29.85, 'max_dpd_12m', ['income_monthly', 'max_dpd_30d', 'months_since_last_delinq', 'n_active_loans', 'n_loans_total', 'utilization']), (15.71, 'utilization', ['income_monthly', 'max_dpd_12m', 'max_dpd_30d', 'n_loans_total'])]
from treecf.viz import plot_alternatives, plot_tradeoff
plot_alternatives(plans, explainer=exp); # deltas in sigma units
plot_tradeoff(plans, target=Target.probability(range=(0.0, cutoff)));