treecf: counterfactuals you can act on¶
Abstract
When a model declines an application, the only honest answer to “what would it take?” is a counterfactual: the smallest feasible change that flips the decision. treecf computes that answer for tree ensembles — under real‑world constraints, verified against the parsed model — and can prove the plan is the cheapest available, or that none exists.
The question SHAP cannot answer¶
Every declined credit application ends with the same question: what would it take? Regulation increasingly requires an answer — adverse‑action notices, the AI Act’s transparency duties — and customers deserve one either way.
The tempting shortcut is to read the answer off an attribution chart: take the biggest SHAP bar and tell the customer to push on it. In a previous note I showed how that goes wrong on a real XGBoost model: the letter says “lower your utilization”, the customer complies, and their probability of default more than doubles. That is not a bug in SHAP. Attribution divides up a prediction that already exists; it says nothing about what happens when the inputs change. “What should this customer do” is a counterfactual question, and you answer it the only way that is honest — by simulating the model until the decision actually flips.
That is the question treecf exists to answer.
What treecf does¶
Given a model, an instance, and a target, treecf searches for the minimal feasible change that moves the model’s raw output into a target interval. It works directly on XGBoost, LightGBM, CatBoost and scikit‑learn tree ensembles, parsed into one shared tree representation — you hand it the model object or its dump file, not a wrapper.
Decision thresholds are first‑class, because in credit risk the threshold is the product. A target is an interval on the model output: a probability cutoff (“get this applicant under 4% PD”), a regression range, or a whole rating‑grade ladder in one call. The result is not “the score improves” but “the application crosses the line the business actually draws”.
Feasible means constrained¶
An unconstrained counterfactual is a fantasy: become five years younger and delete your delinquency history. No customer can follow it, no regulator will accept it, no analyst will sign it. treecf treats the constraint layer as the core of the product, not an afterthought:
- Immutability and direction. Age and bureau history are frozen; income may only plausibly move one way.
- Consistency. Ranges, one‑hot groups, and arbitrary linear inter‑feature rules such as
max_dpd_30d <= max_dpd_12m— declared once, enforced by every backend. - Missing values are values. NaN can be a legitimate counterfactual state — closing an account genuinely turns a number into a blank — with per‑feature opt‑in and explicit transition costs.
- Constraint mining. Candidate invariants are mined from your data and presented for human review — never silently applied.
The plans that come out are ones a person could actually execute — which is the difference between a recourse engine and a search toy.
Verified, not estimated¶
Every candidate plan is float‑verified against the parsed model before it is returned: treecf re‑scores the counterfactual and confirms the output really lands in the target interval. There is no “approximately” in the answer. If treecf returns a plan, the model agrees with it — the same trees, with routing conformance‑tested against the native library, including at threshold boundaries. For anything that ends up in a customer letter or a validation report, that property is not optional.
Proved, not just found¶
Verification says a plan works. It does not say the plan is the cheapest one, and when the search comes back empty it does not say whether a plan exists at all. DiCE returns plans it has checked; it cannot say whether a cheaper one exists or whether none exists. treecf’s exact backend goes further. It branch‑and‑bounds the same candidate grid the heuristic search uses and returns one of three things, each labelled by its proof field:
- An optimal plan.
proof="optimal": no cheaper feasible row exists in the searched grid, under these constraints, for this model. - A certified “no”.
Infeasible(proof="certified"): the search closed every branch — by bound or by exhaustion — without finding one. “No recourse exists within these constraints” becomes a provable statement — the sentence a validator, or a regulator, actually needs. - An honest downgrade. If a budget runs out or a constraint repair cannot be settled, the result says
proof="heuristic"or"search_exhausted", with a warning naming the cause. The claim is withdrawn rather than overstated.
Two constructions build on the proof. A recourse region widens any verified plan into a certified box — “utilization in [0.31, 0.40)”, not “utilization = 0.3972”, with the interval strict or half‑open wherever rounding would otherwise overstate it — with every point in the box provably in‑target and constraint‑feasible. That is the form a customer letter can carry. A recourse menu solves every lever set up to a chosen size and reports which combinations reach the target, at what cost, and which provably cannot: “no acceptance is reachable by changing only dpd_12m” is a certified statement, not an absence of luck.
A certificate is a statement about the artifact handed to the explainer — this parsed model, these compiled constraints, at the moment the search ran. It says nothing about whether the model generalises, or whether the applicant can execute the plan in life. Within that scope, the claim is exact.
Fast enough for the portfolio¶
The heuristic search runs on a Rust engine bundled in the wheel — no toolchain to install, numpy the only Python dependency — and answers in milliseconds per applicant, whole portfolios in one parallel call. The exact backend is slower, as proofs are, and is what you reach for when a plan goes into a letter. Recourse stops being a per‑case favour and becomes a batch job: every declined applicant can get a feasible plan, nightly, and the ones that matter get a proof.
Try it¶
import xgboost as xgb
from treecf import Explainer, Target, Freeze, constraint
# X_train, y_train, X_test: your own data
clf = xgb.XGBClassifier(n_estimators=100, max_depth=4).fit(X_train, y_train)
exp = Explainer(
model=clf, # or "model.json"
background=X_train, # fits the distance normalizers
constraints=[
Freeze("age_of_bureau_file"),
constraint("max_dpd_30d <= max_dpd_12m"),
],
value_policy={"max_dpd_30d": "integer", # counts stay counts
"max_dpd_12m": "integer"},
)
target = Target.probability(range=(0.0, 0.04)) # PD under 4%
x = X_test.iloc[0] # one declined applicant
res = exp.explain(x, target=target, backend="exact", seed=0)
res.proof # "optimal" | "optimal_within_gap" | "heuristic"
res.changes # {"utilization": (0.61, 0.39), "max_dpd_30d": (3, 1)}
boxed = exp.explain(x, target=target, region=True)
boxed.region.describe() # {"utilization": "in [0.31, 0.40)", "max_dpd_30d": "in [0, 1]"}
The same flow, executed end to end on a synthetic credit model: quickstart notebook.
pip install treecf — on PyPI, MIT‑licensed. Model parsers ship as extras (treecf[xgboost]); JSON dumps work without them.
Documentation · GitHub · PyPI
Cite this note / the software (BibTeX)
@misc{wlazlo2026treecfnote,
author = {Wlaz{\l}o, Daniel},
title = {treecf: Counterfactuals You Can Act On},
year = {2026},
month = aug,
howpublished = {datadeer.pl},
url = {https://datadeer.pl/notes/treecf.html}
}
@software{wlazlo2026treecf,
author = {Wlaz{\l}o, Daniel},
title = {treecf: constrained, threshold-aware counterfactual
explanations for tree ensembles},
year = {2026},
license = {MIT},
url = {https://github.com/wlazlod/treecf}
}