bettertrees 0.1.0__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.
Files changed (103) hide show
  1. bettertrees-0.1.0/CHANGELOG.md +36 -0
  2. bettertrees-0.1.0/CITATION.cff +16 -0
  3. bettertrees-0.1.0/LICENSE +29 -0
  4. bettertrees-0.1.0/MANIFEST.in +12 -0
  5. bettertrees-0.1.0/PKG-INFO +368 -0
  6. bettertrees-0.1.0/README.md +311 -0
  7. bettertrees-0.1.0/docs/ITM_INTERACTIONS.md +35 -0
  8. bettertrees-0.1.0/docs/export.md +42 -0
  9. bettertrees-0.1.0/docs/images/frontier.png +0 -0
  10. bettertrees-0.1.0/docs/images/scoreboard.png +0 -0
  11. bettertrees-0.1.0/docs/images/scorecard.png +0 -0
  12. bettertrees-0.1.0/docs/typing.md +28 -0
  13. bettertrees-0.1.0/examples/01_quickstart.ipynb +403 -0
  14. bettertrees-0.1.0/examples/02_interpretation.ipynb +650 -0
  15. bettertrees-0.1.0/examples/03_editing.ipynb +530 -0
  16. bettertrees-0.1.0/examples/credit_data.py +44 -0
  17. bettertrees-0.1.0/pyproject.toml +101 -0
  18. bettertrees-0.1.0/setup.cfg +4 -0
  19. bettertrees-0.1.0/src/bettertrees/__init__.py +57 -0
  20. bettertrees-0.1.0/src/bettertrees/_data.py +115 -0
  21. bettertrees-0.1.0/src/bettertrees/_typing.py +20 -0
  22. bettertrees-0.1.0/src/bettertrees/autotune.py +193 -0
  23. bettertrees-0.1.0/src/bettertrees/bins.py +247 -0
  24. bettertrees-0.1.0/src/bettertrees/builder.py +309 -0
  25. bettertrees-0.1.0/src/bettertrees/estimator.py +607 -0
  26. bettertrees-0.1.0/src/bettertrees/experimental/__init__.py +36 -0
  27. bettertrees-0.1.0/src/bettertrees/experimental/_itm_kernels.py +7 -0
  28. bettertrees-0.1.0/src/bettertrees/experimental/_oblique_kernels.py +29 -0
  29. bettertrees-0.1.0/src/bettertrees/experimental/distill.py +7 -0
  30. bettertrees-0.1.0/src/bettertrees/experimental/interactions.py +7 -0
  31. bettertrees-0.1.0/src/bettertrees/experimental/itm.py +7 -0
  32. bettertrees-0.1.0/src/bettertrees/experimental/oblique.py +612 -0
  33. bettertrees-0.1.0/src/bettertrees/experimental/precision.py +60 -0
  34. bettertrees-0.1.0/src/bettertrees/experimental/ratios.py +7 -0
  35. bettertrees-0.1.0/src/bettertrees/experimental/rulefit.py +7 -0
  36. bettertrees-0.1.0/src/bettertrees/kernels.py +1277 -0
  37. bettertrees-0.1.0/src/bettertrees/lab/__init__.py +50 -0
  38. bettertrees-0.1.0/src/bettertrees/lab/_itm_kernels.py +199 -0
  39. bettertrees-0.1.0/src/bettertrees/lab/distill.py +232 -0
  40. bettertrees-0.1.0/src/bettertrees/lab/interactions.py +68 -0
  41. bettertrees-0.1.0/src/bettertrees/lab/itm.py +726 -0
  42. bettertrees-0.1.0/src/bettertrees/lab/multilevel.py +400 -0
  43. bettertrees-0.1.0/src/bettertrees/lab/ratios.py +119 -0
  44. bettertrees-0.1.0/src/bettertrees/lab/robust.py +402 -0
  45. bettertrees-0.1.0/src/bettertrees/lab/rulefit.py +245 -0
  46. bettertrees-0.1.0/src/bettertrees/multilevel.py +7 -0
  47. bettertrees-0.1.0/src/bettertrees/postprocess.py +300 -0
  48. bettertrees-0.1.0/src/bettertrees/py.typed +0 -0
  49. bettertrees-0.1.0/src/bettertrees/search.py +388 -0
  50. bettertrees-0.1.0/src/bettertrees/splitters.py +191 -0
  51. bettertrees-0.1.0/src/bettertrees/sums/__init__.py +49 -0
  52. bettertrees-0.1.0/src/bettertrees/sums/_common.py +195 -0
  53. bettertrees-0.1.0/src/bettertrees/sums/_kernels.py +847 -0
  54. bettertrees-0.1.0/src/bettertrees/sums/additive.py +270 -0
  55. bettertrees-0.1.0/src/bettertrees/sums/budget.py +111 -0
  56. bettertrees-0.1.0/src/bettertrees/sums/budget.pyi +49 -0
  57. bettertrees-0.1.0/src/bettertrees/sums/compact.py +429 -0
  58. bettertrees-0.1.0/src/bettertrees/sums/edit.py +432 -0
  59. bettertrees-0.1.0/src/bettertrees/sums/explain.py +500 -0
  60. bettertrees-0.1.0/src/bettertrees/sums/imported.py +249 -0
  61. bettertrees-0.1.0/src/bettertrees/sums/prune.py +91 -0
  62. bettertrees-0.1.0/src/bettertrees/sums/robust.py +7 -0
  63. bettertrees-0.1.0/src/bettertrees/sums/screen.py +102 -0
  64. bettertrees-0.1.0/src/bettertrees/sums/smalltrees.py +668 -0
  65. bettertrees-0.1.0/src/bettertrees.egg-info/PKG-INFO +368 -0
  66. bettertrees-0.1.0/src/bettertrees.egg-info/SOURCES.txt +101 -0
  67. bettertrees-0.1.0/src/bettertrees.egg-info/dependency_links.txt +1 -0
  68. bettertrees-0.1.0/src/bettertrees.egg-info/requires.txt +36 -0
  69. bettertrees-0.1.0/src/bettertrees.egg-info/top_level.txt +1 -0
  70. bettertrees-0.1.0/tests/_core.py +111 -0
  71. bettertrees-0.1.0/tests/_reference.py +405 -0
  72. bettertrees-0.1.0/tests/_sklearn_checks.py +26 -0
  73. bettertrees-0.1.0/tests/test_autotune.py +203 -0
  74. bettertrees-0.1.0/tests/test_binary_edges.py +20 -0
  75. bettertrees-0.1.0/tests/test_compat_imports.py +81 -0
  76. bettertrees-0.1.0/tests/test_degenerate.py +110 -0
  77. bettertrees-0.1.0/tests/test_edit.py +192 -0
  78. bettertrees-0.1.0/tests/test_edit_more.py +124 -0
  79. bettertrees-0.1.0/tests/test_export.py +106 -0
  80. bettertrees-0.1.0/tests/test_figs_fast_path.py +156 -0
  81. bettertrees-0.1.0/tests/test_imported.py +148 -0
  82. bettertrees-0.1.0/tests/test_interleaved_name.py +31 -0
  83. bettertrees-0.1.0/tests/test_invariants.py +197 -0
  84. bettertrees-0.1.0/tests/test_itm.py +388 -0
  85. bettertrees-0.1.0/tests/test_multilevel.py +291 -0
  86. bettertrees-0.1.0/tests/test_oblique.py +336 -0
  87. bettertrees-0.1.0/tests/test_package_smoke.py +58 -0
  88. bettertrees-0.1.0/tests/test_prune.py +45 -0
  89. bettertrees-0.1.0/tests/test_robust.py +144 -0
  90. bettertrees-0.1.0/tests/test_row_hist.py +55 -0
  91. bettertrees-0.1.0/tests/test_sums.py +525 -0
  92. bettertrees-0.1.0/tests/test_sums_api.py +212 -0
  93. bettertrees-0.1.0/tests/test_to_sql.py +35 -0
  94. bettertrees-0.1.0/tests/test_tree.py +1083 -0
  95. bettertrees-0.1.0/tests/test_weight_contract.py +40 -0
  96. bettertrees-0.1.0/tests/tree_invariants.py +591 -0
  97. bettertrees-0.1.0/tools/build_examples.py +284 -0
  98. bettertrees-0.1.0/tools/check_distribution.py +110 -0
  99. bettertrees-0.1.0/tools/check_readme.py +35 -0
  100. bettertrees-0.1.0/tools/equivalence.py +63 -0
  101. bettertrees-0.1.0/tools/predict_export.py +79 -0
  102. bettertrees-0.1.0/tools/reference.npz +0 -0
  103. bettertrees-0.1.0/tools/typing_smoke.py +52 -0
@@ -0,0 +1,36 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 (unreleased)
4
+
5
+ First public release. Tested on Python 3.10 and 3.14 (numpy 2.2 to 2.5, scikit-learn 1.7 to 1.9,
6
+ numba 0.68); declared for Python 3.10 to 3.14.
7
+
8
+ - `BudgetClassifier(max_splits=b)`: the Interleaved Tree Model with a fixed rule per cut
9
+ budget, no tuning (evaluated from 4 to 64 cuts; applied unvalidated above).
10
+ - `InterleavedTreeClassifier`, the Interleaved Tree Model (ITM): a logit sum of small trees
11
+ grown together under a budget of cuts (internal nodes), with Newton leaves and a refit of every
12
+ leaf after each cut. `FIGSClassifier` is an alias.
13
+ - Other sums: `SumOfOptimalTrees` (optimal depth-1 to depth-3 trees), `CompactTreeBooster`,
14
+ `AdditiveTreeBooster`, and `LightGBMRefitClassifier` / `from_lightgbm` (a LightGBM model
15
+ as an editable sum).
16
+ - Single trees: `FastDecisionTreeClassifier` and `FastDecisionTreeClassifierCV` (leaf count
17
+ and hierarchical shrinkage chosen by cross-validation).
18
+ - Reading a sum: `explain`, `rules`, `to_dict`, `to_sql`, `get_trees`, `export_text`,
19
+ `predict_contributions`, `plot_contributions`, `plot_shapes`, `to_shap_model`.
20
+ - Editing a sum: `prune`, `set_cut`, `split_leaf`, `add_stump`, `drop_tree`,
21
+ `merge_duplicates`, `set_leaf_value`, `refit_leaves` (partial refit, monotone
22
+ constraints), `monotone_violations`, `cut_alternatives`.
23
+ - `bettertrees.experimental` (no API guarantee): `ObliqueFIGSClassifier`,
24
+ `PrecisionTreeClassifier`.
25
+ - `bettertrees.lab`: research code with negative or inconclusive results, kept so the
26
+ benchmark can be reproduced; not part of the public API.
27
+ - Duplicate-tree merging preserves leaf paths after manual edits. LightGBM imports
28
+ retain float64 input precision and reject unsupported missing-value semantics
29
+ and models exceeding the internal bin capacity.
30
+ - JSON exports include exact numeric bins and trees with a standalone evaluator;
31
+ rounded rules remain available for reading.
32
+ - Zero-weight rows are excluded before binning and cross-validation; conversion
33
+ overflow and invalid cut budgets are rejected explicitly.
34
+ - Public type hints for the high-level API, the `py.typed` marker, a `budget.pyi` stub for
35
+ the delegating `BudgetClassifier`, and a downstream `mypy` check in CI; no new runtime
36
+ dependencies.
@@ -0,0 +1,16 @@
1
+ cff-version: 1.2.0
2
+ message: "If you use bettertrees, please cite it as below. A paper is in preparation; this entry will point to it once it is out."
3
+ title: "bettertrees: Interleaved Tree Models for binary classification under a budget of cuts"
4
+ type: software
5
+ authors:
6
+ - given-names: "Rafael"
7
+ family-names: "do Prado Silva"
8
+ version: 0.1.0
9
+ license: BSD-3-Clause
10
+ repository-code: "https://github.com/rafapras/bettertrees"
11
+ keywords:
12
+ - decision trees
13
+ - interpretable machine learning
14
+ - sums of trees
15
+ - credit scoring
16
+ - scikit-learn
@@ -0,0 +1,29 @@
1
+ BSD 3-Clause License
2
+
3
+ Copyright (c) 2026, The bettertrees developers
4
+ All rights reserved.
5
+
6
+ Redistribution and use in source and binary forms, with or without
7
+ modification, are permitted provided that the following conditions are met:
8
+
9
+ 1. Redistributions of source code must retain the above copyright notice, this
10
+ list of conditions and the following disclaimer.
11
+
12
+ 2. Redistributions in binary form must reproduce the above copyright notice,
13
+ this list of conditions and the following disclaimer in the documentation
14
+ and/or other materials provided with the distribution.
15
+
16
+ 3. Neither the name of the copyright holder nor the names of its
17
+ contributors may be used to endorse or promote products derived from
18
+ this software without specific prior written permission.
19
+
20
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
21
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
22
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
23
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
24
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
25
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
26
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
27
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
28
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
29
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
@@ -0,0 +1,12 @@
1
+ # source distribution: the package plus what is needed to test and read it
2
+ include CHANGELOG.md CITATION.cff
3
+ recursive-include src *.pyi py.typed
4
+ recursive-include tests *.py
5
+ recursive-include examples *.py *.ipynb
6
+ recursive-include tools *.py *.npz
7
+ include docs/*.md
8
+ recursive-include docs/images *.png
9
+ # reference material kept on disk, never shipped
10
+ prune docs/paper
11
+ prune docs/reference
12
+ global-exclude *.py[cod] __pycache__ *.nbc *.nbi
@@ -0,0 +1,368 @@
1
+ Metadata-Version: 2.4
2
+ Name: bettertrees
3
+ Version: 0.1.0
4
+ Summary: Interleaved Tree Models: small, readable sums of trees for binary classification under a budget of cuts
5
+ Author: Rafael do Prado Silva
6
+ License-Expression: BSD-3-Clause
7
+ Project-URL: Homepage, https://github.com/rafapras/bettertrees
8
+ Project-URL: Repository, https://github.com/rafapras/bettertrees
9
+ Project-URL: Issues, https://github.com/rafapras/bettertrees/issues
10
+ Project-URL: Changelog, https://github.com/rafapras/bettertrees/blob/main/CHANGELOG.md
11
+ Keywords: decision trees,interpretable machine learning,FIGS,sum of trees,credit scoring,scikit-learn
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Science/Research
14
+ Classifier: Intended Audience :: Financial and Insurance Industry
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3 :: Only
18
+ Classifier: Programming Language :: Python :: 3.10
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Programming Language :: Python :: 3.13
22
+ Classifier: Programming Language :: Python :: 3.14
23
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
24
+ Requires-Python: >=3.10
25
+ Description-Content-Type: text/markdown
26
+ License-File: LICENSE
27
+ Requires-Dist: numpy<3,>=1.26
28
+ Requires-Dist: numba>=0.60
29
+ Requires-Dist: scikit-learn<2,>=1.6
30
+ Provides-Extra: lightgbm
31
+ Requires-Dist: lightgbm>=4.0; extra == "lightgbm"
32
+ Provides-Extra: plot
33
+ Requires-Dist: matplotlib>=3.8; extra == "plot"
34
+ Provides-Extra: shap
35
+ Requires-Dist: shap>=0.45; extra == "shap"
36
+ Provides-Extra: all
37
+ Requires-Dist: lightgbm>=4.0; extra == "all"
38
+ Requires-Dist: matplotlib>=3.8; extra == "all"
39
+ Requires-Dist: shap>=0.45; extra == "all"
40
+ Requires-Dist: pandas>=2.0; extra == "all"
41
+ Provides-Extra: test
42
+ Requires-Dist: pytest>=8; extra == "test"
43
+ Requires-Dist: scipy; extra == "test"
44
+ Requires-Dist: pandas; extra == "test"
45
+ Requires-Dist: matplotlib; extra == "test"
46
+ Requires-Dist: shap; extra == "test"
47
+ Requires-Dist: lightgbm>=4.0; extra == "test"
48
+ Provides-Extra: examples
49
+ Requires-Dist: pandas; extra == "examples"
50
+ Requires-Dist: matplotlib; extra == "examples"
51
+ Requires-Dist: nbformat; extra == "examples"
52
+ Requires-Dist: nbclient; extra == "examples"
53
+ Requires-Dist: ipykernel; extra == "examples"
54
+ Provides-Extra: typing
55
+ Requires-Dist: mypy>=1.15; extra == "typing"
56
+ Dynamic: license-file
57
+
58
+ # bettertrees
59
+
60
+ **Interleaved Tree Models: small, readable sums of trees for binary classification, under a
61
+ budget of cuts.**
62
+
63
+ [![License: BSD-3-Clause](https://img.shields.io/badge/license-BSD--3--Clause-blue.svg)](LICENSE)
64
+ ![Python 3.10+](https://img.shields.io/badge/python-3.10%2B-blue.svg)
65
+ [![CI](https://github.com/rafapras/bettertrees/actions/workflows/ci.yml/badge.svg)](https://github.com/rafapras/bettertrees/actions/workflows/ci.yml)
66
+
67
+ ![AUC against the number of cuts: the ITM above LightGBM and CART of the same size](https://raw.githubusercontent.com/rafapras/bettertrees/main/docs/images/frontier.png)
68
+
69
+ ## Why
70
+
71
+ Where a decision must be explained line by line, as in credit scoring and in the biological
72
+ sciences, the binding constraint is the size of the model: a scorecard with a few dozen bins, a
73
+ tree with a few dozen nodes. bettertrees asks how much predictive power fits in each cut. Its main
74
+ model, the Interleaved Tree Model (ITM), is a logit sum of a few small trees that share one budget
75
+ of cuts. You set the budget; a fixed rule sets everything else, so there is nothing to
76
+ tune per dataset.
77
+
78
+ ## Install
79
+
80
+ ```bash
81
+ pip install bettertrees # numpy, numba, scikit-learn
82
+ pip install "bettertrees[all]" # + LightGBM screening, matplotlib plots, SHAP, pandas
83
+ ```
84
+
85
+ From source: `pip install "git+https://github.com/rafapras/bettertrees"`. Python 3.10 or newer (tested on 3.10 and 3.14).
86
+ The Numba kernels compile on first use (about 5 to 10 seconds, once per environment) and are
87
+ cached afterwards. The public API has [type hints](https://github.com/rafapras/bettertrees/blob/main/docs/typing.md)
88
+ (`py.typed`); pandas inputs stay at the array-like level.
89
+
90
+ ## Quickstart
91
+
92
+ This example uses a pandas DataFrame; install the `all` extra above. For NumPy-only
93
+ use, omit `as_frame=True` when loading the data.
94
+
95
+ ```python
96
+ from sklearn.datasets import load_breast_cancer
97
+ from sklearn.metrics import roc_auc_score
98
+ from sklearn.model_selection import train_test_split
99
+ from bettertrees import BudgetClassifier
100
+
101
+ X, y = load_breast_cancer(return_X_y=True, as_frame=True) # a DataFrame keeps the column names
102
+ X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.3, random_state=0, stratify=y)
103
+
104
+ model = BudgetClassifier(max_splits=8).fit(X_train, y_train)
105
+ auc = roc_auc_score(y_test, model.predict_proba(X_test)[:, 1])
106
+ print(f"{model.n_splits_} cuts in {len(model.trees_)} trees, test AUC {auc:.3f}")
107
+ ```
108
+
109
+ ```
110
+ 8 cuts in 7 trees, test AUC 0.975
111
+ ```
112
+
113
+ `BudgetClassifier(max_splits=b)` is the ITM with the rule of the benchmark: `lam = 2b`; up to 8
114
+ cuts a full Newton step capped at 4 logits, above 8 cuts a step of 0.3. The rule was evaluated
115
+ from 4 to 64 cuts. `InterleavedTreeClassifier` is the same model with every setting exposed.
116
+
117
+ ## Reading the model
118
+
119
+ The whole model is the printout. To score a row, add the base and one value per tree, then apply
120
+ the logistic function.
121
+
122
+ ```python
123
+ print(model.explain())
124
+ ```
125
+
126
+ ```
127
+ logit P(y = 1) = base +0.5242 + sum of 7 trees (8 cuts)
128
+ tree 1:
129
+ +1.7784 if worst perimeter <= 108.5
130
+ -2.3190 if worst perimeter > 108.5
131
+ tree 2:
132
+ +1.5100 if worst concave points <= 0.1415
133
+ -1.8678 if worst concave points > 0.1415
134
+ tree 3:
135
+ +1.3691 if worst texture <= 23.07
136
+ -1.2124 if worst texture > 23.07
137
+ tree 4:
138
+ +0.8157 if mean concavity <= 0.09267
139
+ -0.8322 if mean concavity > 0.09267
140
+ tree 5:
141
+ +0.5433 if mean texture <= 20.15
142
+ -0.6155 if mean texture > 20.15
143
+ tree 6:
144
+ +0.7050 if worst radius <= 15.87
145
+ -0.2938 if worst radius > 15.87 and worst texture <= 19.78
146
+ -0.9831 if worst radius > 15.87 and worst texture > 19.78
147
+ tree 7:
148
+ +0.4234 if worst symmetry <= 0.2852
149
+ -0.4383 if worst symmetry > 0.2852
150
+ ```
151
+
152
+ Six trees are single cuts, one additive effect each; tree 6 is an interaction, the effect of
153
+ texture among large radii. The same model as data, per row, and as SQL:
154
+
155
+ ```python
156
+ model.rules()[:2] # [(tree, conditions, logit value)] per leaf
157
+ # [(0, ['worst perimeter <= 108.5'], 1.7783520345848123),
158
+ # (0, ['worst perimeter > 108.5'], -2.3190229737929564)]
159
+
160
+ model.predict_contributions(X_test[:1]) # one column per tree; base + row sum = logit
161
+ # [[-2.31902297 -1.86779044 -1.21240149 -0.8322431 -0.61554356 -0.98310833 -0.43829661]]
162
+
163
+ print(model.to_sql(table="patients")) # the model as one query, no Python needed to score
164
+ ```
165
+
166
+ ```sql
167
+ -- logit P(y = 1) = base + sum of 7 tree columns (8 cuts); p = 1 / (1 + EXP(-score))
168
+ WITH contributions AS (
169
+ SELECT *,
170
+ CASE -- tree 1
171
+ WHEN "worst perimeter" <= 108.5 THEN 1.77835
172
+ ELSE -2.31902
173
+ END AS "t1_worst perimeter",
174
+ ...
175
+ CASE -- tree 6
176
+ WHEN "worst radius" <= 15.870001 THEN 0.704956
177
+ WHEN "worst radius" > 15.870001 AND "worst texture" <= 19.779999 THEN -0.293802
178
+ ELSE -0.983108
179
+ END AS "t6_worst radius_worst texture",
180
+ ...
181
+ FROM patients
182
+ ), scored AS (
183
+ SELECT *, 0.524249 -- base
184
+ + "t1_worst perimeter"
185
+ ...
186
+ AS score
187
+ FROM contributions
188
+ )
189
+ SELECT *, 1.0 / (1.0 + EXP(-score)) AS p
190
+ FROM scored;
191
+ ```
192
+
193
+ Also on every sum: `to_dict()` (JSON), `get_trees()` (arrays, as scikit-learn's `tree_`),
194
+ `export_text()`, `plot_contributions(x)` (waterfall of one prediction), `plot_shapes()` (shape
195
+ functions of the single-feature trees) and `to_shap_model()` (exact TreeSHAP with
196
+ `shap.TreeExplainer`).
197
+
198
+ For deployment, `to_dict()` stores exact bins and tree arrays alongside the rounded
199
+ rules. The [export guide](https://github.com/rafapras/bettertrees/blob/main/docs/export.md) includes an independent evaluator and the
200
+ input precision and missing-value conventions. SQL printouts round leaf values to
201
+ `precision` significant digits (6 by default); validate the generated query against
202
+ the fitted model on deployment inputs.
203
+
204
+ ## Results
205
+
206
+ From the benchmark of the paper (in preparation): 56 binary classification datasets from
207
+ TabArena, the AutoML Benchmark, the suite of Grinsztajn et al. and Kaggle, with 10 thousand to
208
+ 2.2 million rows. Three evaluation partitions per dataset up to 100 thousand rows, one 80/20
209
+ holdout above; the partition used to choose the rule is excluded. Every rival is held to the same
210
+ budget in its own unit: CART is grown to b + 1 leaves, LightGBM gets K trees of l leaves with
211
+ K(l − 1) ≤ b, RGF is refitted to exactly b cuts.
212
+
213
+ Mean AUC difference in points (ITM minus rival; one point = 0.01 AUC), with the number of datasets
214
+ on which the ITM has the higher AUC. Positive = ITM better.
215
+
216
+ | rival (same budget unless noted) | 4 cuts | 16 cuts | 32 cuts | 64 cuts |
217
+ |---|---|---|---|---|
218
+ | CART (scikit-learn, tuned) | +1.89 (50/56) | +2.37 (52/56) | +2.30 (53/56) | +1.80 (25/28) |
219
+ | LightGBM, tuned with 16 trials | +0.51 (44/56) | +0.57 (51/56) | +0.32 (20/28) | +0.02 (16/28) |
220
+ | LightGBM, tuned with 100 trials | +0.64 (38/49) | +0.45 (43/49) | +0.59 (26/28) | — |
221
+ | RGF, tuned | −0.18 (28/56) | −0.03 (23/56) | +0.15 (31/56) | — |
222
+ | WoE scorecard (no size limit) | −3.95 (4/56) | +0.41 (37/56) | +1.43 (50/56) | +1.98 (24/28) |
223
+
224
+ Cells with 28 datasets cover only 10–100 thousand rows (64 cuts, and LightGBM with 16 trials at
225
+ 32 cuts) or only above 100 thousand rows (LightGBM with 100 trials at 32 cuts); LightGBM with 100
226
+ trials was not run on the 7 datasets of 50–100 thousand rows. "—": not run.
227
+
228
+ In short: the ITM beats CART of the same size by about 2 AUC points at every budget, beats
229
+ LightGBM of the same size from 4 to 32 cuts and ties it at 64, and ties a tuned RGF, its closest
230
+ ancestor (with its library defaults, RGF is 2.5 to 3.6 points behind). Dataset by dataset at 16
231
+ cuts:
232
+
233
+ ![ITM minus CART and minus LightGBM tuned with 100 trials, per dataset, at 16 cuts](https://raw.githubusercontent.com/rafapras/bettertrees/main/docs/images/scoreboard.png)
234
+
235
+ Against a weight-of-evidence scorecard (up to 10 bins per variable, no size limit; a median of
236
+ 157 steps, one step per bin boundary), the ITM loses with 4 and 8 cuts and passes the scorecard
237
+ at 16:
238
+
239
+ ![ITM minus a WoE scorecard by model size](https://raw.githubusercontent.com/rafapras/bettertrees/main/docs/images/scorecard.png)
240
+
241
+ **Cost.** The ITM is cheaper to obtain because it is not tuned. At 16 cuts on 10–50 thousand
242
+ rows it takes a median of 0.11 seconds against 5.26 seconds for LightGBM tuned with 100 trials
243
+ (median per-dataset ratio 47); above 100 thousand rows the ratio is 2.9 (7.7 against 22.3
244
+ seconds), because the tuned control searches on a 100 thousand-row sample.
245
+
246
+ ## How it works
247
+
248
+ The ITM scores a row as `logit P(y = 1) = base + T_1(x) + ... + T_K(x)`, where each `T_k` is a
249
+ small tree with values in log-odds. Growth starts with no trees. At each step every leaf of every
250
+ tree, and the root of a new tree, is a candidate; the cut with the largest Newton gain
251
+ `G_l²/(H_l + λ) + G_r²/(H_r + λ) − G²/(H + λ)`, at the margin of the other trees, is added; its
252
+ two leaves take a Newton step `−G/(H + λ)`, and one sweep then refits every leaf of every tree.
253
+ Growth stops after b cuts (internal nodes, one per split operation); a (feature, threshold)
254
+ pair may repeat, so the model has at most b distinct pairs. The growth across trees comes from FIGS
255
+ (Tan et al.), the refit of all leaves from RGF (Johnson and Zhang); the ITM replaces FIGS's
256
+ mean-residual leaves with Newton steps in log-odds, refits after every cut, and replaces tuning with a
257
+ fixed rule. Features are binned into 32 quantile bins;
258
+ missing values get a bin of their own and always go left.
259
+
260
+ ## Limitations
261
+
262
+ - **Binary classification only** for the sums (they raise on a multiclass target; wrap them in
263
+ `OneVsRestClassifier`). The single trees are multiclass.
264
+ - **Evaluated from 4 to 64 cuts.** Above 64 the rule is applied without validation; at 64 the ITM
265
+ only ties LightGBM.
266
+ - **Not faster per fit.** One ITM fit is slower than one LightGBM fit of the same size (16 cuts,
267
+ 50 features, one thread: 0.15, 1.38 and 15 seconds at 10 thousand, 100 thousand and one
268
+ million rows, against 0.05, 0.60 and 2.62 for LightGBM). The time saved comes from not tuning.
269
+ - **Small budgets and many weak effects.** With 4 and 8 cuts a WoE scorecard of about 150 steps
270
+ has the higher AUC; where many variables each carry a weak monotone effect, many cheap bins beat
271
+ a few cuts.
272
+ - **Deep interactions.** Where the target is itself a deep interaction (in the benchmark,
273
+ `electricity` and the hierarchical `pol`), a single tree or boosting is the right shape and the
274
+ sum gains little over CART.
275
+ - **The rule** was chosen among about fifty variants on development partitions of the same pool
276
+ of datasets, and the step cap at 4 and 8 cuts was added after the first evaluation results. It
277
+ has not been validated on datasets outside the project.
278
+ - **Ordered numeric inputs.** Thresholds assume that the order of a feature's values means
279
+ something; integer-coded categories with thousands of levels are better served by native
280
+ categorical splits.
281
+
282
+ ## What else is in the package
283
+
284
+ | estimator | what it is |
285
+ |---|---|
286
+ | `BudgetClassifier` | the ITM with the fixed rule per budget; start here |
287
+ | `InterleavedTreeClassifier` | the ITM with every setting exposed; `FIGSClassifier` is an alias |
288
+ | `SumOfOptimalTrees` | a logit sum of a few optimal (exhaustive search) trees of depth 1–3, with backfitting |
289
+ | `CompactTreeBooster` | shrunken boosting of optimal trees, identical trees merged, budget in distinct cuts |
290
+ | `AdditiveTreeBooster` | a long sum of optimal depth-1/2 trees with early stopping (no budget) |
291
+ | `LightGBMRefitClassifier`, `from_lightgbm` | a LightGBM model imported as an editable sum (exact predictions), with its leaves refitted jointly |
292
+ | `FastDecisionTreeClassifier`, `FastDecisionTreeClassifierCV` | a single CART-style tree (Gini), multiclass; the CV version chooses the leaf count and hierarchical shrinkage |
293
+
294
+ `bettertrees.sums` also exposes `TreeSum` (the editable sum returned by `from_lightgbm`),
295
+ `SmallTree` (one tree of a sum, in `trees_`) and `screen_features` (bootstrap screening of
296
+ single cuts on a residual).
297
+
298
+ All estimators follow the scikit-learn API (pipelines, `GridSearchCV`, `CalibratedClassifierCV`,
299
+ `permutation_importance`, `partial_dependence`, pickle), accept NaN and `sample_weight`, and keep
300
+ DataFrame column names.
301
+
302
+ `from_lightgbm` supports binary models with numerical splits, at most 254 distinct
303
+ thresholds per feature, and missing values routed left. It rejects categorical splits,
304
+ `zero_as_missing` and NaN routed right, whose semantics cannot be preserved.
305
+ Rows with zero sample weight are excluded from training and binning; positive weights
306
+ change the objective mass and do not generally mean repeating rows, especially with
307
+ row-based validation or LightGBM binning.
308
+
309
+ **Editing.** Every sum can be edited and re-estimated, and all outputs follow the edit:
310
+
311
+ ```python
312
+ model.set_cut(tree, node, "income", 50_000) # move or replace a cut (snaps to a bin edge)
313
+ model.split_leaf(tree, leaf, "debt", 2.5) # add a cut by hand
314
+ model.add_stump("age", 65) # add a rule as a new tree
315
+ model.prune(tree, node) # collapse a cut into a leaf
316
+ model.refit_leaves(X, y, monotone={"income": -1}) # refit, with monotone constraints
317
+ model.cut_alternatives(X_val, y_val, tree, node, epsilon=0.01) # near-equivalent cuts
318
+ ```
319
+
320
+ **Experimental and lab.**
321
+
322
+ - `bettertrees.experimental` (no API stability guarantee): `ObliqueFIGSClassifier`, the ITM with
323
+ cuts on linear combinations of two features, which helped at 4 and 8 cuts and added little at
324
+ 16 and 32; `PrecisionTreeClassifier`, a tree whose cuts maximize one class's precision.
325
+ - `bettertrees.lab` is research code from the benchmark with negative or inconclusive results:
326
+ product terms between trees, bagged and Rashomon structure selection, distillation from a
327
+ teacher, pair and ratio features, RuleFit, and a multilevel tree of optimal depth-2 blocks.
328
+ **It is not part of the public API and carries no stability guarantee**; it is kept so that the
329
+ results can be reproduced.
330
+
331
+ **Examples.** Three notebooks in [`examples/`](https://github.com/rafapras/bettertrees/tree/main/examples), run by CI on simulated credit data
332
+ with known effects: [`01_quickstart`](https://github.com/rafapras/bettertrees/blob/main/examples/01_quickstart.ipynb),
333
+ [`02_interpretation`](https://github.com/rafapras/bettertrees/blob/main/examples/02_interpretation.ipynb) and
334
+ [`03_editing`](https://github.com/rafapras/bettertrees/blob/main/examples/03_editing.ipynb).
335
+
336
+ ## Credits
337
+
338
+ | work | what bettertrees borrows |
339
+ |---|---|
340
+ | **FIGS**: Tan, Singh, Nasseri, Agarwal, et al. "Fast Interpretable Greedy-Tree Sums." arXiv:2201.11931 (2022); [imodels](https://github.com/csinva/imodels) | Growth across trees: the next cut may deepen any leaf of any tree or start a new one. |
341
+ | **RGF**: Johnson, Zhang. "Learning Nonlinear Functions Using Regularized Greedy Forest." *IEEE TPAMI* (2014) | The refit of all leaf values as the forest grows. |
342
+ | **XGBoost**: Chen, Guestrin. *KDD* (2016) | The second-order gain `G²/(H+λ)` and leaf value `−G/(H+λ)`. |
343
+ | **LightGBM**: Ke et al. *NeurIPS* (2017); scikit-learn's HistGradientBoosting | Quantile histograms with a bin for missing values; best-first growth. |
344
+ | **Hierarchical Shrinkage**: Agarwal, Tan, Ronen, Singh, Yu. *ICML* (2022) | Leaf values shrunk toward their ancestors in the single tree. |
345
+ | **MurTree**: Demirović et al. *JMLR* (2022); **ConTree**: Briţa, van der Linden, Demirović. *AAAI* (2025) | The depth-two solver behind `SumOfOptimalTrees`, here on bins with the Newton gain. |
346
+ | **GA2M / EBM**: Lou, Caruana, Gehrke, Hooker. *KDD* (2013); Nori et al. InterpretML (2019) | Additive models with pairwise interactions and shape plots. |
347
+ | **CART**: Breiman, Friedman, Olshen, Stone (1984); Pagallo and Haussler, *Machine Learning* (1990) | The tree engine, cost-complexity pruning and the replication problem that motivates sums. |
348
+ | **SHAP / TreeSHAP**: Lundberg et al. *Nature Machine Intelligence* (2020) | `to_shap_model()` for exact TreeSHAP. |
349
+ | **scikit-learn** (Pedregosa et al., *JMLR* 2011) and **Numba** (Lam, Pitrou, Seibert, 2015) | The estimator API; all kernels. |
350
+
351
+ ## Citation
352
+
353
+ A paper on the ITM and the benchmark is in preparation. Until it is out, please cite the software
354
+ ([`CITATION.cff`](https://github.com/rafapras/bettertrees/blob/main/CITATION.cff)):
355
+
356
+ ```bibtex
357
+ @software{bettertrees,
358
+ title = {bettertrees: Interleaved Tree Models for binary classification under a budget of cuts},
359
+ author = {rafapras},
360
+ year = {2026},
361
+ version = {0.1.0},
362
+ url = {https://github.com/rafapras/bettertrees}
363
+ }
364
+ ```
365
+
366
+ ## License
367
+
368
+ BSD-3-Clause; see [`LICENSE`](https://github.com/rafapras/bettertrees/blob/main/LICENSE).