treecf 0.3.1__tar.gz → 0.3.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- treecf-0.3.2/PKG-INFO +188 -0
- treecf-0.3.2/README.md +121 -0
- {treecf-0.3.1 → treecf-0.3.2}/pyproject.toml +1 -1
- {treecf-0.3.1 → treecf-0.3.2}/rust/Cargo.lock +1 -1
- {treecf-0.3.1 → treecf-0.3.2}/rust/Cargo.toml +1 -1
- {treecf-0.3.1 → treecf-0.3.2}/src/treecf/__init__.py +1 -1
- {treecf-0.3.1 → treecf-0.3.2}/src/treecf/_menu.py +1 -1
- {treecf-0.3.1 → treecf-0.3.2}/src/treecf/api.py +20 -13
- {treecf-0.3.1 → treecf-0.3.2}/src/treecf/audit.py +5 -0
- treecf-0.3.2/src/treecf/datasets/__init__.py +59 -0
- treecf-0.3.2/src/treecf/datasets/credit_model.json +1 -0
- treecf-0.3.1/PKG-INFO +0 -143
- treecf-0.3.1/README.md +0 -76
- {treecf-0.3.1 → treecf-0.3.2}/LICENSE +0 -0
- {treecf-0.3.1 → treecf-0.3.2}/rust/src/cells.rs +0 -0
- {treecf-0.3.1 → treecf-0.3.2}/rust/src/constraints.rs +0 -0
- {treecf-0.3.1 → treecf-0.3.2}/rust/src/exact/domains.rs +0 -0
- {treecf-0.3.1 → treecf-0.3.2}/rust/src/exact/mod.rs +0 -0
- {treecf-0.3.1 → treecf-0.3.2}/rust/src/exact/orderpairs.rs +0 -0
- {treecf-0.3.1 → treecf-0.3.2}/rust/src/exact/propagation.rs +0 -0
- {treecf-0.3.1 → treecf-0.3.2}/rust/src/exact/refine.rs +0 -0
- {treecf-0.3.1 → treecf-0.3.2}/rust/src/exact/search.rs +0 -0
- {treecf-0.3.1 → treecf-0.3.2}/rust/src/exact/test_support.rs +0 -0
- {treecf-0.3.1 → treecf-0.3.2}/rust/src/exact/trace.rs +0 -0
- {treecf-0.3.1 → treecf-0.3.2}/rust/src/ga.rs +0 -0
- {treecf-0.3.1 → treecf-0.3.2}/rust/src/interrupt.rs +0 -0
- {treecf-0.3.1 → treecf-0.3.2}/rust/src/ir.rs +0 -0
- {treecf-0.3.1 → treecf-0.3.2}/rust/src/lib.rs +0 -0
- {treecf-0.3.1 → treecf-0.3.2}/rust/src/py.rs +0 -0
- {treecf-0.3.1 → treecf-0.3.2}/rust/src/region_slab.rs +0 -0
- {treecf-0.3.1 → treecf-0.3.2}/rust/src/regions.rs +0 -0
- {treecf-0.3.1 → treecf-0.3.2}/src/treecf/_errors.py +0 -0
- {treecf-0.3.1 → treecf-0.3.2}/src/treecf/_json.py +0 -0
- {treecf-0.3.1 → treecf-0.3.2}/src/treecf/_portfolio.py +0 -0
- {treecf-0.3.1 → treecf-0.3.2}/src/treecf/_region_slab.py +0 -0
- {treecf-0.3.1 → treecf-0.3.2}/src/treecf/aim/__init__.py +0 -0
- {treecf-0.3.1 → treecf-0.3.2}/src/treecf/aim/cells.py +0 -0
- {treecf-0.3.1 → treecf-0.3.2}/src/treecf/backends/__init__.py +0 -0
- {treecf-0.3.1 → treecf-0.3.2}/src/treecf/backends/_exact_bounds.py +0 -0
- {treecf-0.3.1 → treecf-0.3.2}/src/treecf/backends/_exact_domains.py +0 -0
- {treecf-0.3.1 → treecf-0.3.2}/src/treecf/backends/_exact_orderpairs.py +0 -0
- {treecf-0.3.1 → treecf-0.3.2}/src/treecf/backends/_exact_profile.py +0 -0
- {treecf-0.3.1 → treecf-0.3.2}/src/treecf/backends/_exact_propagation.py +0 -0
- {treecf-0.3.1 → treecf-0.3.2}/src/treecf/backends/_exact_refine.py +0 -0
- {treecf-0.3.1 → treecf-0.3.2}/src/treecf/backends/_exact_trace.py +0 -0
- {treecf-0.3.1 → treecf-0.3.2}/src/treecf/backends/exact.py +0 -0
- {treecf-0.3.1 → treecf-0.3.2}/src/treecf/backends/exact_rust.py +0 -0
- {treecf-0.3.1 → treecf-0.3.2}/src/treecf/backends/genetic.py +0 -0
- {treecf-0.3.1 → treecf-0.3.2}/src/treecf/backends/genetic_rust.py +0 -0
- {treecf-0.3.1 → treecf-0.3.2}/src/treecf/backends/regions_rust.py +0 -0
- {treecf-0.3.1 → treecf-0.3.2}/src/treecf/batch.py +0 -0
- {treecf-0.3.1 → treecf-0.3.2}/src/treecf/constraints/__init__.py +0 -0
- {treecf-0.3.1 → treecf-0.3.2}/src/treecf/constraints/compile.py +0 -0
- {treecf-0.3.1 → treecf-0.3.2}/src/treecf/constraints/flatten.py +0 -0
- {treecf-0.3.1 → treecf-0.3.2}/src/treecf/constraints/objects.py +0 -0
- {treecf-0.3.1 → treecf-0.3.2}/src/treecf/constraints/parser.py +0 -0
- {treecf-0.3.1 → treecf-0.3.2}/src/treecf/ir/__init__.py +0 -0
- {treecf-0.3.1 → treecf-0.3.2}/src/treecf/ir/conformance.py +0 -0
- {treecf-0.3.1 → treecf-0.3.2}/src/treecf/ir/evaluate.py +0 -0
- {treecf-0.3.1 → treecf-0.3.2}/src/treecf/ir/flatten.py +0 -0
- {treecf-0.3.1 → treecf-0.3.2}/src/treecf/ir/model.py +0 -0
- {treecf-0.3.1 → treecf-0.3.2}/src/treecf/ir/parsers/__init__.py +0 -0
- {treecf-0.3.1 → treecf-0.3.2}/src/treecf/ir/parsers/_catboost_cat.py +0 -0
- {treecf-0.3.1 → treecf-0.3.2}/src/treecf/ir/parsers/_float32.py +0 -0
- {treecf-0.3.1 → treecf-0.3.2}/src/treecf/ir/parsers/catboost.py +0 -0
- {treecf-0.3.1 → treecf-0.3.2}/src/treecf/ir/parsers/json_dump.py +0 -0
- {treecf-0.3.1 → treecf-0.3.2}/src/treecf/ir/parsers/lightgbm.py +0 -0
- {treecf-0.3.1 → treecf-0.3.2}/src/treecf/ir/parsers/sklearn.py +0 -0
- {treecf-0.3.1 → treecf-0.3.2}/src/treecf/ir/parsers/xgboost.py +0 -0
- {treecf-0.3.1 → treecf-0.3.2}/src/treecf/mining.py +0 -0
- {treecf-0.3.1 → treecf-0.3.2}/src/treecf/objective.py +0 -0
- {treecf-0.3.1 → treecf-0.3.2}/src/treecf/plausibility.py +0 -0
- {treecf-0.3.1 → treecf-0.3.2}/src/treecf/py.typed +0 -0
- {treecf-0.3.1 → treecf-0.3.2}/src/treecf/regions.py +0 -0
- {treecf-0.3.1 → treecf-0.3.2}/src/treecf/targets.py +0 -0
- {treecf-0.3.1 → treecf-0.3.2}/src/treecf/viz.py +0 -0
- {treecf-0.3.1 → treecf-0.3.2}/src/treecf/viz_batch.py +0 -0
treecf-0.3.2/PKG-INFO
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: treecf
|
|
3
|
+
Version: 0.3.2
|
|
4
|
+
Classifier: Development Status :: 4 - Beta
|
|
5
|
+
Classifier: Intended Audience :: Science/Research
|
|
6
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
7
|
+
Classifier: Operating System :: OS Independent
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
12
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
13
|
+
Classifier: Typing :: Typed
|
|
14
|
+
Requires-Dist: numpy>=1.24
|
|
15
|
+
Requires-Dist: xgboost>=2.0 ; extra == 'all'
|
|
16
|
+
Requires-Dist: lightgbm>=4.3 ; extra == 'all'
|
|
17
|
+
Requires-Dist: catboost>=1.2 ; extra == 'all'
|
|
18
|
+
Requires-Dist: scikit-learn>=1.4 ; extra == 'all'
|
|
19
|
+
Requires-Dist: matplotlib>=3.8 ; extra == 'all'
|
|
20
|
+
Requires-Dist: catboost>=1.2 ; extra == 'catboost'
|
|
21
|
+
Requires-Dist: maturin>=1.7 ; extra == 'dev'
|
|
22
|
+
Requires-Dist: pytest>=8.0 ; extra == 'dev'
|
|
23
|
+
Requires-Dist: pytest-cov>=5.0 ; extra == 'dev'
|
|
24
|
+
Requires-Dist: hypothesis>=6.100 ; extra == 'dev'
|
|
25
|
+
Requires-Dist: ruff>=0.5 ; extra == 'dev'
|
|
26
|
+
Requires-Dist: mypy>=1.10 ; extra == 'dev'
|
|
27
|
+
Requires-Dist: xgboost>=2.0 ; extra == 'dev'
|
|
28
|
+
Requires-Dist: lightgbm>=4.3 ; extra == 'dev'
|
|
29
|
+
Requires-Dist: catboost>=1.2 ; extra == 'dev'
|
|
30
|
+
Requires-Dist: scikit-learn>=1.4 ; extra == 'dev'
|
|
31
|
+
Requires-Dist: matplotlib>=3.8 ; extra == 'dev'
|
|
32
|
+
Requires-Dist: probcal>=0.2 ; extra == 'dev'
|
|
33
|
+
Requires-Dist: mkdocs>=1.6 ; extra == 'docs'
|
|
34
|
+
Requires-Dist: mkdocs-material>=9.5 ; extra == 'docs'
|
|
35
|
+
Requires-Dist: mkdocstrings[python]>=0.27 ; extra == 'docs'
|
|
36
|
+
Requires-Dist: pymdown-extensions>=10.9 ; extra == 'docs'
|
|
37
|
+
Requires-Dist: mkdocs-jupyter>=0.24 ; extra == 'docs'
|
|
38
|
+
Requires-Dist: ipykernel>=6.29 ; extra == 'docs'
|
|
39
|
+
Requires-Dist: lightgbm>=4.3 ; extra == 'lightgbm'
|
|
40
|
+
Requires-Dist: scikit-learn>=1.4 ; extra == 'sklearn'
|
|
41
|
+
Requires-Dist: pytest>=8.0 ; extra == 'test'
|
|
42
|
+
Requires-Dist: hypothesis>=6.100 ; extra == 'test'
|
|
43
|
+
Requires-Dist: probcal>=0.2 ; extra == 'test'
|
|
44
|
+
Requires-Dist: matplotlib>=3.8 ; extra == 'viz'
|
|
45
|
+
Requires-Dist: xgboost>=2.0 ; extra == 'xgboost'
|
|
46
|
+
Provides-Extra: all
|
|
47
|
+
Provides-Extra: catboost
|
|
48
|
+
Provides-Extra: dev
|
|
49
|
+
Provides-Extra: docs
|
|
50
|
+
Provides-Extra: lightgbm
|
|
51
|
+
Provides-Extra: sklearn
|
|
52
|
+
Provides-Extra: test
|
|
53
|
+
Provides-Extra: viz
|
|
54
|
+
Provides-Extra: xgboost
|
|
55
|
+
License-File: LICENSE
|
|
56
|
+
Summary: Constrained, threshold-aware counterfactual explanations for tree ensembles (XGBoost, LightGBM, CatBoost, sklearn) — fast Rust genetic search, exact optimality proofs, certified infeasibility, and recourse regions.
|
|
57
|
+
Keywords: counterfactual,xai,interpretability,recourse,gbdt,xgboost,lightgbm,catboost,credit-risk
|
|
58
|
+
Author-email: Daniel Wlazlo <wlazlo.daniel@gmail.com>
|
|
59
|
+
License: MIT
|
|
60
|
+
Requires-Python: >=3.11
|
|
61
|
+
Description-Content-Type: text/markdown; charset=UTF-8; variant=GFM
|
|
62
|
+
Project-URL: Changelog, https://github.com/wlazlod/treecf/blob/main/CHANGELOG.md
|
|
63
|
+
Project-URL: Documentation, https://wlazlod.github.io/treecf/
|
|
64
|
+
Project-URL: Homepage, https://github.com/wlazlod/treecf
|
|
65
|
+
Project-URL: Issues, https://github.com/wlazlod/treecf/issues
|
|
66
|
+
|
|
67
|
+
# treecf
|
|
68
|
+
|
|
69
|
+
[](https://doi.org/10.5281/zenodo.22069503)
|
|
70
|
+
[](https://pypi.org/project/treecf/)
|
|
71
|
+
[](https://pypi.org/project/treecf/)
|
|
72
|
+
[](https://github.com/wlazlod/treecf/actions/workflows/ci.yml)
|
|
73
|
+
[](LICENSE)
|
|
74
|
+
|
|
75
|
+
**Constrained, threshold-aware counterfactual explanations for tree ensembles.**
|
|
76
|
+
|
|
77
|
+
`treecf` answers the question: *"what is the minimal, feasible change to this instance such
|
|
78
|
+
that the model's output lands in a target interval?"* — for XGBoost, LightGBM, CatBoost and
|
|
79
|
+
scikit-learn tree ensembles.
|
|
80
|
+
|
|
81
|
+

|
|
82
|
+
|
|
83
|
+
> On [PyPI](https://pypi.org/project/treecf/). See the [documentation](https://wlazlod.github.io/treecf/) for concepts and tutorials.
|
|
84
|
+
|
|
85
|
+
## Why another counterfactual package?
|
|
86
|
+
|
|
87
|
+
- **Tree-native and fast.** Models are parsed into a shared tree IR and the constrained
|
|
88
|
+
search runs on a bundled Rust core, typically in milliseconds; every result is
|
|
89
|
+
float-verified against the parsed model before it is returned, and the parsers are
|
|
90
|
+
conformance-tested against the native library.
|
|
91
|
+
- **Optional proofs, inside a measured envelope.** `backend="exact"` returns
|
|
92
|
+
`proof="optimal"` when no cheaper plan exists under the declared objective (weighted
|
|
93
|
+
distance, plus a per-feature term only if you set `sparsity_weight`), and a completed
|
|
94
|
+
search that finds nothing returns `Infeasible(proof="certified")`. Proofs scale with the
|
|
95
|
+
number of levers the search may move, not with the model's width: on the measured matrix
|
|
96
|
+
the refine search certifies up to 200 trees with 12 free features inside 60 s and no
|
|
97
|
+
20-feature model beyond the smallest one (50 trees at depth 3) — while on the 300-tree,
|
|
98
|
+
50-feature model a coalition of up to
|
|
99
|
+
three levers certifies in under half a second with `search="refine"`, so wide models get
|
|
100
|
+
proofs once the levers are restricted with `Freeze`, coalitions, or a `recourse_menu`. A
|
|
101
|
+
search that runs out of budget returns its best plan labelled `heuristic` and warns; it
|
|
102
|
+
never claims more.
|
|
103
|
+
- **Recourse regions.** Any verified counterfactual widens into a certified box — "reduce
|
|
104
|
+
utilization below 0.40", not "to 0.3972" — with every point in the box provably in-target
|
|
105
|
+
and constraint-feasible; works with every backend.
|
|
106
|
+
- **Real constraints.** Immutability, directionality, ranges, one-hot consistency, linear
|
|
107
|
+
inter-feature rules such as `max_dpd_30d <= max_dpd_12m`, and NaN as a legitimate value
|
|
108
|
+
with its own transition cost — declared once, enforced by every backend.
|
|
109
|
+
- **Menus, diverse plans, certificates.** `recourse_menu` solves every lever set up to a
|
|
110
|
+
size and says which combinations provably cannot work; `explain_diverse` returns the
|
|
111
|
+
cheapest plans with distinct lever sets; `certificate` turns any result into a
|
|
112
|
+
self-contained JSON record a validator re-checks later.
|
|
113
|
+
|
|
114
|
+
On a 120-tree model and 100 declined rows, treecf's plans cost a seventh of DiCE's at a fifth
|
|
115
|
+
of the time; NICE is four times faster per instance, and its plans cost 2.7 times more and
|
|
116
|
+
cannot take constraints. The measured tables and the honest reading are on the
|
|
117
|
+
[benchmarks page](https://wlazlod.github.io/treecf/concepts/backends/#against-other-cf-libraries).
|
|
118
|
+
|
|
119
|
+
Not for you if: the model is not a tree ensemble; you want sets of plans diverse by distance
|
|
120
|
+
rather than by the levers they use (DiCE does that); you need a proof over dozens of free
|
|
121
|
+
levers at once without restricting them (see the [proof envelope](https://wlazlod.github.io/treecf/concepts/certification/#the-proof-envelope-measured));
|
|
122
|
+
or you need a frozen API — treecf is in beta, see
|
|
123
|
+
[API stability](https://wlazlod.github.io/treecf/api-stability/).
|
|
124
|
+
|
|
125
|
+
## Installation
|
|
126
|
+
|
|
127
|
+
```bash
|
|
128
|
+
pip install "treecf[xgboost,viz]" # wheels for Linux, macOS, Windows; no Rust toolchain needed
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
numpy is the only Python dependency; the extras add a parser for your training library and
|
|
132
|
+
the plots. JSON model dumps parse without the training library, so explanations can be
|
|
133
|
+
generated on a scoring host that has neither it nor a solver.
|
|
134
|
+
|
|
135
|
+
## Quick look
|
|
136
|
+
|
|
137
|
+
`credit_demo()` returns a packaged credit model, background rows, and one declined
|
|
138
|
+
applicant. It is new in this version; on an older installed package, substitute your own
|
|
139
|
+
model, as the quickstart notebook does.
|
|
140
|
+
|
|
141
|
+
```python
|
|
142
|
+
from treecf import Explainer, Freeze, Monotone, Target
|
|
143
|
+
from treecf.datasets import credit_demo
|
|
144
|
+
|
|
145
|
+
model, X, x = credit_demo()
|
|
146
|
+
target = Target.probability(range=(0.0, 0.05))
|
|
147
|
+
exp = Explainer(
|
|
148
|
+
model, background=X,
|
|
149
|
+
constraints=[Freeze("occupation"), Monotone("tenure_months", "increase")],
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
res = exp.explain(x, target=target, seed=0)
|
|
153
|
+
res.changes # {'income': (4678.0, 6932.4)}
|
|
154
|
+
res.proof # 'heuristic'
|
|
155
|
+
|
|
156
|
+
proved = exp.explain(x, target=target, backend="exact", region=True, seed=0)
|
|
157
|
+
proved.proof # 'optimal'
|
|
158
|
+
proved.region.describe() # {'income': 'in [6.58e+03, 7.81e+03] (data-limited)',
|
|
159
|
+
# 'utilization': 'in [0.428, 0.541)', ...}
|
|
160
|
+
|
|
161
|
+
menu = exp.recourse_menu(x, target=target, max_levers=2)
|
|
162
|
+
menu.describe()["dpd_12m"] # 'no acceptance is reachable by changing only dpd_12m'
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
## Learn more
|
|
166
|
+
|
|
167
|
+
- [How it works](https://wlazlod.github.io/treecf/how-it-works/) — the pipeline from objective to verified answer.
|
|
168
|
+
- [Certification](https://wlazlod.github.io/treecf/concepts/certification/) — what a proof covers and where it stops.
|
|
169
|
+
- [Credit-risk walkthrough](https://wlazlod.github.io/treecf/notebooks/02-credit-risk-tutorial/) — a batch workflow end to end.
|
|
170
|
+
- [probcal integration](https://wlazlod.github.io/treecf/guide/probcal/) — recourse against calibrated cutoffs.
|
|
171
|
+
|
|
172
|
+
## Cite
|
|
173
|
+
|
|
174
|
+
```bibtex
|
|
175
|
+
@software{wlazlo_treecf,
|
|
176
|
+
author = {Wlazło, Daniel},
|
|
177
|
+
title = {treecf: constrained, threshold-aware counterfactual explanations for tree ensembles},
|
|
178
|
+
doi = {10.5281/zenodo.22069503},
|
|
179
|
+
url = {https://github.com/wlazlod/treecf},
|
|
180
|
+
license = {MIT}
|
|
181
|
+
}
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
## Contributing and license
|
|
185
|
+
|
|
186
|
+
MIT. See [CONTRIBUTING.md](CONTRIBUTING.md) for dev setup, the test layers, and the
|
|
187
|
+
project's hard invariants; report security issues privately per [SECURITY.md](SECURITY.md).
|
|
188
|
+
|
treecf-0.3.2/README.md
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# treecf
|
|
2
|
+
|
|
3
|
+
[](https://doi.org/10.5281/zenodo.22069503)
|
|
4
|
+
[](https://pypi.org/project/treecf/)
|
|
5
|
+
[](https://pypi.org/project/treecf/)
|
|
6
|
+
[](https://github.com/wlazlod/treecf/actions/workflows/ci.yml)
|
|
7
|
+
[](LICENSE)
|
|
8
|
+
|
|
9
|
+
**Constrained, threshold-aware counterfactual explanations for tree ensembles.**
|
|
10
|
+
|
|
11
|
+
`treecf` answers the question: *"what is the minimal, feasible change to this instance such
|
|
12
|
+
that the model's output lands in a target interval?"* — for XGBoost, LightGBM, CatBoost and
|
|
13
|
+
scikit-learn tree ensembles.
|
|
14
|
+
|
|
15
|
+

|
|
16
|
+
|
|
17
|
+
> On [PyPI](https://pypi.org/project/treecf/). See the [documentation](https://wlazlod.github.io/treecf/) for concepts and tutorials.
|
|
18
|
+
|
|
19
|
+
## Why another counterfactual package?
|
|
20
|
+
|
|
21
|
+
- **Tree-native and fast.** Models are parsed into a shared tree IR and the constrained
|
|
22
|
+
search runs on a bundled Rust core, typically in milliseconds; every result is
|
|
23
|
+
float-verified against the parsed model before it is returned, and the parsers are
|
|
24
|
+
conformance-tested against the native library.
|
|
25
|
+
- **Optional proofs, inside a measured envelope.** `backend="exact"` returns
|
|
26
|
+
`proof="optimal"` when no cheaper plan exists under the declared objective (weighted
|
|
27
|
+
distance, plus a per-feature term only if you set `sparsity_weight`), and a completed
|
|
28
|
+
search that finds nothing returns `Infeasible(proof="certified")`. Proofs scale with the
|
|
29
|
+
number of levers the search may move, not with the model's width: on the measured matrix
|
|
30
|
+
the refine search certifies up to 200 trees with 12 free features inside 60 s and no
|
|
31
|
+
20-feature model beyond the smallest one (50 trees at depth 3) — while on the 300-tree,
|
|
32
|
+
50-feature model a coalition of up to
|
|
33
|
+
three levers certifies in under half a second with `search="refine"`, so wide models get
|
|
34
|
+
proofs once the levers are restricted with `Freeze`, coalitions, or a `recourse_menu`. A
|
|
35
|
+
search that runs out of budget returns its best plan labelled `heuristic` and warns; it
|
|
36
|
+
never claims more.
|
|
37
|
+
- **Recourse regions.** Any verified counterfactual widens into a certified box — "reduce
|
|
38
|
+
utilization below 0.40", not "to 0.3972" — with every point in the box provably in-target
|
|
39
|
+
and constraint-feasible; works with every backend.
|
|
40
|
+
- **Real constraints.** Immutability, directionality, ranges, one-hot consistency, linear
|
|
41
|
+
inter-feature rules such as `max_dpd_30d <= max_dpd_12m`, and NaN as a legitimate value
|
|
42
|
+
with its own transition cost — declared once, enforced by every backend.
|
|
43
|
+
- **Menus, diverse plans, certificates.** `recourse_menu` solves every lever set up to a
|
|
44
|
+
size and says which combinations provably cannot work; `explain_diverse` returns the
|
|
45
|
+
cheapest plans with distinct lever sets; `certificate` turns any result into a
|
|
46
|
+
self-contained JSON record a validator re-checks later.
|
|
47
|
+
|
|
48
|
+
On a 120-tree model and 100 declined rows, treecf's plans cost a seventh of DiCE's at a fifth
|
|
49
|
+
of the time; NICE is four times faster per instance, and its plans cost 2.7 times more and
|
|
50
|
+
cannot take constraints. The measured tables and the honest reading are on the
|
|
51
|
+
[benchmarks page](https://wlazlod.github.io/treecf/concepts/backends/#against-other-cf-libraries).
|
|
52
|
+
|
|
53
|
+
Not for you if: the model is not a tree ensemble; you want sets of plans diverse by distance
|
|
54
|
+
rather than by the levers they use (DiCE does that); you need a proof over dozens of free
|
|
55
|
+
levers at once without restricting them (see the [proof envelope](https://wlazlod.github.io/treecf/concepts/certification/#the-proof-envelope-measured));
|
|
56
|
+
or you need a frozen API — treecf is in beta, see
|
|
57
|
+
[API stability](https://wlazlod.github.io/treecf/api-stability/).
|
|
58
|
+
|
|
59
|
+
## Installation
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
pip install "treecf[xgboost,viz]" # wheels for Linux, macOS, Windows; no Rust toolchain needed
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
numpy is the only Python dependency; the extras add a parser for your training library and
|
|
66
|
+
the plots. JSON model dumps parse without the training library, so explanations can be
|
|
67
|
+
generated on a scoring host that has neither it nor a solver.
|
|
68
|
+
|
|
69
|
+
## Quick look
|
|
70
|
+
|
|
71
|
+
`credit_demo()` returns a packaged credit model, background rows, and one declined
|
|
72
|
+
applicant. It is new in this version; on an older installed package, substitute your own
|
|
73
|
+
model, as the quickstart notebook does.
|
|
74
|
+
|
|
75
|
+
```python
|
|
76
|
+
from treecf import Explainer, Freeze, Monotone, Target
|
|
77
|
+
from treecf.datasets import credit_demo
|
|
78
|
+
|
|
79
|
+
model, X, x = credit_demo()
|
|
80
|
+
target = Target.probability(range=(0.0, 0.05))
|
|
81
|
+
exp = Explainer(
|
|
82
|
+
model, background=X,
|
|
83
|
+
constraints=[Freeze("occupation"), Monotone("tenure_months", "increase")],
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
res = exp.explain(x, target=target, seed=0)
|
|
87
|
+
res.changes # {'income': (4678.0, 6932.4)}
|
|
88
|
+
res.proof # 'heuristic'
|
|
89
|
+
|
|
90
|
+
proved = exp.explain(x, target=target, backend="exact", region=True, seed=0)
|
|
91
|
+
proved.proof # 'optimal'
|
|
92
|
+
proved.region.describe() # {'income': 'in [6.58e+03, 7.81e+03] (data-limited)',
|
|
93
|
+
# 'utilization': 'in [0.428, 0.541)', ...}
|
|
94
|
+
|
|
95
|
+
menu = exp.recourse_menu(x, target=target, max_levers=2)
|
|
96
|
+
menu.describe()["dpd_12m"] # 'no acceptance is reachable by changing only dpd_12m'
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
## Learn more
|
|
100
|
+
|
|
101
|
+
- [How it works](https://wlazlod.github.io/treecf/how-it-works/) — the pipeline from objective to verified answer.
|
|
102
|
+
- [Certification](https://wlazlod.github.io/treecf/concepts/certification/) — what a proof covers and where it stops.
|
|
103
|
+
- [Credit-risk walkthrough](https://wlazlod.github.io/treecf/notebooks/02-credit-risk-tutorial/) — a batch workflow end to end.
|
|
104
|
+
- [probcal integration](https://wlazlod.github.io/treecf/guide/probcal/) — recourse against calibrated cutoffs.
|
|
105
|
+
|
|
106
|
+
## Cite
|
|
107
|
+
|
|
108
|
+
```bibtex
|
|
109
|
+
@software{wlazlo_treecf,
|
|
110
|
+
author = {Wlazło, Daniel},
|
|
111
|
+
title = {treecf: constrained, threshold-aware counterfactual explanations for tree ensembles},
|
|
112
|
+
doi = {10.5281/zenodo.22069503},
|
|
113
|
+
url = {https://github.com/wlazlod/treecf},
|
|
114
|
+
license = {MIT}
|
|
115
|
+
}
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
## Contributing and license
|
|
119
|
+
|
|
120
|
+
MIT. See [CONTRIBUTING.md](CONTRIBUTING.md) for dev setup, the test layers, and the
|
|
121
|
+
project's hard invariants; report security issues privately per [SECURITY.md](SECURITY.md).
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "treecf"
|
|
3
|
-
version = "0.3.
|
|
3
|
+
version = "0.3.2"
|
|
4
4
|
description = "Constrained, threshold-aware counterfactual explanations for tree ensembles (XGBoost, LightGBM, CatBoost, sklearn) — fast Rust genetic search, exact optimality proofs, certified infeasibility, and recourse regions."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = { text = "MIT" }
|
|
@@ -221,7 +221,7 @@ def _calibrated_readout(target: Target, score_raw: float) -> float | None:
|
|
|
221
221
|
_DEFAULT_WARM_START = True
|
|
222
222
|
_DEFAULT_NODE_BUDGET = 2_000_000
|
|
223
223
|
_DEFAULT_GAP = 0.0
|
|
224
|
-
_DEFAULT_SEARCH = "
|
|
224
|
+
_DEFAULT_SEARCH = "refine"
|
|
225
225
|
_DEFAULT_TIME_BUDGET_S = 10.0
|
|
226
226
|
_SEARCH_MODES = ("classic", "refine")
|
|
227
227
|
|
|
@@ -574,12 +574,13 @@ class Explainer:
|
|
|
574
574
|
additive rather than deducted from the budget. ``gap`` lets the exact
|
|
575
575
|
search settle for a counterfactual within that relative fraction of
|
|
576
576
|
the true optimum, reported through ``proof="optimal_within_gap"``.
|
|
577
|
-
``search`` picks the exact engine: ``"
|
|
578
|
-
assigns one candidate value per feature at a time; ``"refine"``
|
|
577
|
+
``search`` picks the exact engine: ``"refine"`` (the default)
|
|
579
578
|
first holds each numeric feature to a range of routing cells and
|
|
580
|
-
descends only where the score bound forces it
|
|
581
|
-
|
|
582
|
-
|
|
579
|
+
descends only where the score bound forces it; ``"classic"``
|
|
580
|
+
assigns one candidate value per feature at a time. Both prove the
|
|
581
|
+
same optimum and the same infeasibility certificates — the refine
|
|
582
|
+
search often in far fewer nodes on models with many thresholds per
|
|
583
|
+
feature, which is why it is the default — though the row the two
|
|
583
584
|
returns may be a different argmin of the same cost.
|
|
584
585
|
|
|
585
586
|
An exact search can return a feasible row with ``proof="heuristic"``
|
|
@@ -1196,11 +1197,13 @@ class Explainer:
|
|
|
1196
1197
|
elapsed = time.monotonic() - start
|
|
1197
1198
|
|
|
1198
1199
|
if res.stats["completed"] is False:
|
|
1199
|
-
# the size of the space is computed only on this path
|
|
1200
|
-
#
|
|
1200
|
+
# the size of the space is computed only on this path, and without
|
|
1201
|
+
# the presolve pass: sizing the presolved space means bracketing
|
|
1202
|
+
# every candidate state through every tree in Python, which on a
|
|
1203
|
+
# wide, deep model costs seconds after the budget already ended
|
|
1201
1204
|
log10_states: float | None = None
|
|
1202
1205
|
if cast(int, res.stats["nodes_expanded"]) >= node_budget or elapsed >= time_budget_s:
|
|
1203
|
-
log10_states = cast(float, self._search_profile(x,
|
|
1206
|
+
log10_states = cast(float, self._search_profile(x, None)["log10_states"])
|
|
1204
1207
|
degradation = _degradation_for(
|
|
1205
1208
|
res, node_budget, time_budget_s, elapsed, seed, log10_states
|
|
1206
1209
|
)
|
|
@@ -1453,7 +1456,7 @@ class Explainer:
|
|
|
1453
1456
|
max_levers: int = 3,
|
|
1454
1457
|
mode: str = "minimal",
|
|
1455
1458
|
backend: str = "exact",
|
|
1456
|
-
search: str = "
|
|
1459
|
+
search: str = "refine",
|
|
1457
1460
|
seed: int | None = None,
|
|
1458
1461
|
time_budget_s: float | None = None,
|
|
1459
1462
|
total_budget_s: float | None = None,
|
|
@@ -1502,7 +1505,7 @@ class Explainer:
|
|
|
1502
1505
|
backend
|
|
1503
1506
|
``"exact"`` (certifies) or ``"genetic"``.
|
|
1504
1507
|
search
|
|
1505
|
-
Exact search mode, ``"
|
|
1508
|
+
Exact search mode, ``"refine"`` (the default) or ``"classic"``.
|
|
1506
1509
|
seed
|
|
1507
1510
|
Passed to every solve.
|
|
1508
1511
|
time_budget_s
|
|
@@ -1733,7 +1736,11 @@ class Explainer:
|
|
|
1733
1736
|
``seed``/``node_budget``/``gap``/``time_budget_s``/``warm_start``/
|
|
1734
1737
|
``search`` are recorded under ``solve.declared`` when given: the
|
|
1735
1738
|
result object does not carry them, so they are caller-supplied, and
|
|
1736
|
-
the block's name makes that provenance explicit.
|
|
1739
|
+
the block's name makes that provenance explicit. ``search`` is the one
|
|
1740
|
+
exception: an exact result reports the engine that ran in its own
|
|
1741
|
+
``solver_stats``, so it is recorded there even when not given, and a
|
|
1742
|
+
certificate never depends on the default of the release that issued
|
|
1743
|
+
it.
|
|
1737
1744
|
|
|
1738
1745
|
Parameters
|
|
1739
1746
|
----------
|
|
@@ -1758,7 +1765,7 @@ class Explainer:
|
|
|
1758
1765
|
The warm-start setting the solve ran with, likewise.
|
|
1759
1766
|
search
|
|
1760
1767
|
The exact search mode (``"classic"`` or ``"refine"``) the solve
|
|
1761
|
-
ran with
|
|
1768
|
+
ran with; taken from the result's ``solver_stats`` when omitted.
|
|
1762
1769
|
|
|
1763
1770
|
Returns
|
|
1764
1771
|
-------
|
|
@@ -485,6 +485,11 @@ def build_certificate(
|
|
|
485
485
|
declared["warm_start"] = warm_start
|
|
486
486
|
if search is not None:
|
|
487
487
|
declared["search"] = search
|
|
488
|
+
elif "search" in result.solver_stats:
|
|
489
|
+
# an exact solve reports which engine ran; record it, so a certificate
|
|
490
|
+
# never leaves the engine to be inferred from the issuing release's
|
|
491
|
+
# default
|
|
492
|
+
declared["search"] = str(result.solver_stats["search"])
|
|
488
493
|
solve: dict[str, object] = {
|
|
489
494
|
"backend": _backend_of(result.solver_stats),
|
|
490
495
|
"proof": result.proof,
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""A packaged credit-shaped demo: a committed model, a background sample, one declined row.
|
|
2
|
+
|
|
3
|
+
Every example in the documentation starts from ``credit_demo()``, so it runs
|
|
4
|
+
as written on a fresh install without a training library. The model is a
|
|
5
|
+
committed LightGBM JSON dump of a small synthetic credit-risk classifier over
|
|
6
|
+
five features — ``income``, ``utilization``, ``dpd_12m``, ``tenure_months``,
|
|
7
|
+
and a native categorical ``occupation`` — and the background rows follow a
|
|
8
|
+
fixed synthetic recipe, so a given seed always returns the same data.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from importlib import resources
|
|
14
|
+
|
|
15
|
+
import numpy as np
|
|
16
|
+
import numpy.typing as npt
|
|
17
|
+
|
|
18
|
+
from treecf.ir.model import EnsembleIR, apply_categories
|
|
19
|
+
from treecf.ir.parsers import parse_model
|
|
20
|
+
|
|
21
|
+
FloatArray = npt.NDArray[np.float64]
|
|
22
|
+
|
|
23
|
+
OCCUPATIONS = ("student", "clerk", "manager", "retired")
|
|
24
|
+
"""Display names of the demo model's ``occupation`` codes, in code order."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def credit_demo(seed: int = 7, n: int = 400) -> tuple[EnsembleIR, FloatArray, FloatArray]:
|
|
28
|
+
"""The demo model, a background sample, and one declined applicant.
|
|
29
|
+
|
|
30
|
+
Parameters
|
|
31
|
+
----------
|
|
32
|
+
seed
|
|
33
|
+
Seed of the background sample; the model itself is fixed.
|
|
34
|
+
n
|
|
35
|
+
Number of background rows.
|
|
36
|
+
|
|
37
|
+
Returns
|
|
38
|
+
-------
|
|
39
|
+
``(model, X, x)``: the parsed model (an ``EnsembleIR`` with the
|
|
40
|
+
occupation names installed, accepted by ``Explainer`` directly), an
|
|
41
|
+
``(n, 5)`` background matrix, and its second row, an applicant the
|
|
42
|
+
model scores well above a 5% default probability.
|
|
43
|
+
"""
|
|
44
|
+
with resources.as_file(resources.files(__name__).joinpath("credit_model.json")) as path:
|
|
45
|
+
model = apply_categories(parse_model(str(path)), {"occupation": OCCUPATIONS})
|
|
46
|
+
rng = np.random.default_rng(seed)
|
|
47
|
+
X = np.column_stack(
|
|
48
|
+
[
|
|
49
|
+
rng.normal(loc=4200.0, scale=1600.0, size=n), # income
|
|
50
|
+
np.clip(rng.beta(2.0, 3.5, size=n), 0.0, 1.0), # utilization
|
|
51
|
+
np.floor(rng.exponential(scale=6.0, size=n)), # dpd_12m
|
|
52
|
+
np.floor(rng.uniform(3, 240, size=n)), # tenure_months
|
|
53
|
+
rng.integers(0, 4, size=n).astype(np.float64), # occupation codes
|
|
54
|
+
]
|
|
55
|
+
)
|
|
56
|
+
return model, X, X[1].copy()
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
__all__ = ["OCCUPATIONS", "credit_demo"]
|