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 model, in milliseconds.
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 — the same trees, the same floating‑point arithmetic — agrees with it. For anything that ends up in a customer letter or a validation report, that property is not optional.
Fast enough for the portfolio¶
The constrained search runs on a Rust engine bundled in the wheel — no toolchain to install, numpy the only Python dependency — measured at 44–58× the speed of the equivalent numpy implementation. In practice that means milliseconds per applicant and whole portfolios in one parallel call. Recourse stops being a per‑case favour and becomes a batch job: every declined applicant can get a feasible plan, nightly. The same speed serves validation teams probing how close the portfolio sits to the cutoff, and analysts running honest what‑ifs instead of reading bars.
Try it¶
from treecf import Explainer, Target, constraint, Freeze
exp = Explainer(
model="model.json", # native object or dump file
background=X_train_sample,
constraints=[
constraint("max_dpd_30d <= max_dpd_12m"),
Freeze("age_of_bureau_file"),
],
)
res = exp.explain(x, target=Target.probability(range=(0.0, 0.04)), seed=0)
pip install treecf — v0.1.1 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},
version = {0.1.1},
license = {MIT},
url = {https://github.com/wlazlod/treecf}
}