pytrees-rs
pytrees-rs learns anytime optimal decision trees. It is written mostly in Rust and comes with a Python wrapper that follows the scikit-learn API, so the estimators work with pipelines, grid search and cross-validation.
A greedy learner such as CART picks, at each node, the split that looks best right now, and never revisits it. That is fast, but the tree it builds can be much worse than the best tree of the same size. The learners here look further ahead:
| Estimator | Features | What it learns |
|---|---|---|
DL85Classifier | binary | The optimal tree of a given depth (DL8.5) |
LGDTClassifier | binary | A tree grown top-down whose tests are chosen with a depth-2 lookahead (LGDT) |
ConTreeClassifier | continuous | The optimal tree of a given depth (ConTree) |
DL85Cluster | binary | A clustering whose clusters are the leaves of an optimal tree |
An optimal tree is the tree with the fewest training errors among all trees
of at most max_depth levels with at least min_sup training rows per leaf.
Finding it is NP-hard, and on large datasets or deep trees the search may not
finish. Two things make that manageable:
- Every search has a time limit (
max_time) and returns the best tree found so far, withstatus_telling you whether it was proven optimal. - The anytime searches find a good tree early and keep improving it. For
DL8.5 they are configured with the search rules; for
ConTree with
use_lds=True. Both estimators have afit_anytimemethod that reports each improvement as it happens.
Small optimal trees are often as accurate as much larger greedy ones, and they are easy to read: a depth-3 tree is at most seven tests.
Where to go next
- Installation and the quick start.
- The estimators chapter documents every parameter.
- The command line tools and the Rust crates give access to the same algorithms without Python.
- Publications lists the papers behind each algorithm.
Installation
From PyPI
pip install pytrees-rs
The package needs Python 3.10 or later, and installs NumPy, SciPy and
scikit-learn. Prebuilt wheels are published for Linux (x86_64 and aarch64),
macOS (Intel and Apple silicon) and Windows (x64). The package is imported as
pytrees:
import pytrees
from pytrees import ConTreeClassifier, DL85Classifier, LGDTClassifier, DL85Cluster
From source
Building from source needs a Rust toolchain, version 1.77 or later. Install it with rustup if you do not have one. Then:
git clone https://github.com/haroldks/pytrees-rs.git
cd pytrees-rs
pip install .
pip builds the Rust extension with maturin, which
it installs on its own.
For development, build the package in place instead, so that Python changes are picked up without reinstalling:
pip install maturin
maturin develop --release
pytest python/tests
Command line tools
The command line tools are plain Rust binaries:
cargo build --release -p dtrees-cli -p contree-cli
./target/release/dtrees --help
./target/release/con-tree --help
See Command line tools for their options.
Quick start
An optimal tree on continuous data
ConTreeClassifier works directly on numeric features:
from sklearn.datasets import load_breast_cancer
from sklearn.model_selection import train_test_split
from pytrees import ConTreeClassifier
X, y = load_breast_cancer(return_X_y=True)
X_train, X_test, y_train, y_test = train_test_split(X, y, random_state=0)
clf = ConTreeClassifier(max_depth=2, min_sup=5, max_time=60)
clf.fit(X_train, y_train)
print(clf.status_) # "optimal" if the search finished
print(clf.train_error_) # number of misclassified training rows
print(clf.score(X_test, y_test))
status_ is "optimal" when the search proved that no tree of that depth
makes fewer training errors. If the time limit is reached first, it is
"time_limit" and the tree is the best one found.
An optimal tree on binary data
DL85Classifier and LGDTClassifier need features that are 0 or 1. Put a
discretiser in front of them:
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import KBinsDiscretizer
from pytrees import DL85Classifier, LGDTClassifier
binarize = KBinsDiscretizer(n_bins=4, encode="onehot-dense")
optimal = make_pipeline(binarize, DL85Classifier(max_depth=3, min_sup=5, max_time=60))
optimal.fit(X_train, y_train)
lookahead = make_pipeline(binarize, LGDTClassifier(max_depth=6, min_sup=5))
lookahead.fit(X_train, y_train)
print(optimal.score(X_test, y_test), lookahead.score(X_test, y_test))
DL8.5 finds the optimal tree, which gets expensive beyond depth 4 or 5 on datasets with many features. LGDT is not optimal but scales to deep trees.
Watching an anytime search
On a hard problem, the anytime searches give you a good tree early.
fit_anytime calls a function each time the tree improves:
from pytrees import ConTreeClassifier
def report(error, seconds, status):
print(f"{seconds:7.2f}s error={error} {status}")
clf = ConTreeClassifier(max_depth=4, sort_by_heuristic=True, max_time=60)
clf.fit_anytime(X_train, y_train, callback=report)
For DL8.5, pass one of the search rules, for example
DL85Classifier(discrepancy=DiscrepancyRule()), and call fit_anytime the
same way.
Looking at the tree
Every estimator stores its tree in tree_, and to_dot exports it to
Graphviz:
dot = clf.to_dot(feature_names=load_breast_cancer().feature_names)
import graphviz # pip install graphviz
graphviz.Source(dot).render("tree", format="png")
apply(X) returns the leaf each row reaches, and decision_path(X) the nodes
it passes through. See The fitted tree.
Using scikit-learn tools
The estimators can be cloned, pickled and tuned like any other:
from sklearn.model_selection import GridSearchCV
search = GridSearchCV(
ConTreeClassifier(max_time=30),
{"max_depth": [1, 2, 3], "min_sup": [1, 5, 20]},
cv=5,
)
search.fit(X_train, y_train)
print(search.best_params_)
Estimators
All estimators live in the pytrees package and follow the scikit-learn
conventions: parameters are set in the constructor, fit returns the
estimator, fitted attributes end with an underscore, and the estimators can be
cloned, pickled and used inside Pipeline, GridSearchCV or
cross_val_score.
| Estimator | Task | Features | Search |
|---|---|---|---|
DL85Classifier | classification | binary | optimal, optionally anytime |
LGDTClassifier | classification | binary | greedy with a depth-2 lookahead |
ConTreeClassifier | classification | continuous | optimal, optionally anytime |
DL85Cluster | clustering | binary | optimal |
Some behaviour is shared by all of them:
- Labels.
ycan hold any labelsnp.uniqueaccepts (integers, strings, and so on).classes_lists them, andpredictreturns them. - Binary features. DL8.5, LGDT and DL85Cluster only accept 0 and 1 in
Xand raise aValueErrorotherwise, both infitand inpredict. Use aBinarizer, aKBinsDiscretizerwith one-hot encoding, or your own thresholds to prepare the data. - The fitted tree.
tree_is apytrees.tree.Treewith the same layout as scikit-learn’s.apply,decision_pathandto_dotwork on it for every estimator. - Search results.
train_error_is the training error of the tree,status_says why the search stopped, andstatistics_holds counters such as the search time (duration, in seconds) and the size of the cache.
DL85Classifier
DL85Classifier finds the decision tree of at most max_depth levels with
the fewest training errors, on binary features. It implements DL8.5, a
dynamic programming search with branch-and-bound and a cache of subproblems:
the rows that reach a node depend only on the tests on the path to it, so each
set of tests is solved once and reused.
from pytrees import DL85Classifier
clf = DL85Classifier(max_depth=3, min_sup=5, max_time=60)
clf.fit(X, y) # X holds only 0 and 1
clf.predict(X_test)
clf.status_ # "optimal" or "time_limit"
Parameters
| Parameter | Default | Description |
|---|---|---|
min_sup | 1 | Minimum number of training rows in each leaf. |
max_depth | 1 | Maximum depth of the tree. |
max_error | None | Stop as soon as a tree with at most this error is found. None searches for the optimum. |
max_time | 600.0 | Seconds before the search stops with the best tree found so far. |
always_sort | True | Sort the features by heuristic at every node, rather than only at the root. |
heuristic | "none" | Order in which features are tried: "none", "gini", "information_gain" or "weighted_entropy". It does not change the optimal tree, but a good order finds good trees sooner and prunes more. It also drives the search rules. |
fast_d2 | True | Solve the last two levels with a specialised depth-2 solver (from MurTree), which is much faster than the general search. Not used with error_function_input="indices". |
similarity_lb | True | Bound the error of a node from similar nodes already solved, to prune earlier. Turned off when a search rule or an error_function is given. |
dynamic_branching | True | For each feature, search first the branch with the higher known lower bound, which leaves a tighter bound for the other one. Turned off when a search rule is given. |
error_function_input | "class_counts" | What a custom error_function receives: "class_counts" or "indices" (the rows in the node). |
discrepancy, gain, topk, restart, purity | None | Search rules that make the search anytime. |
error_function | None | A custom error for the leaves, see below. |
Fitted attributes
| Attribute | Description |
|---|---|
classes_, n_classes_ | The class labels and their number. |
n_features_in_ | Number of features seen in fit. |
tree_ | The fitted tree, or None if the search found no tree. |
train_error_ | Training error of the tree, as the error function measures it. |
status_ | "optimal", or "time_limit" if the search stopped at max_time. |
statistics_ | duration, cache_size, cache_hits, restarts, search_space_size (nodes visited), sibling_pruning, n_samples, n_features, error. |
Anytime search
With the default settings DL8.5 is a depth-first search: it may spend all its time in one part of the search space and return a poor tree when the time limit hits. The search rules fix that by restricting each pass of the search to the choices the heuristic prefers, then restarting with a wider budget until a pass completes. The last complete pass proves the tree optimal.
from pytrees import DL85Classifier
from pytrees.rules import DiscrepancyRule
clf = DL85Classifier(
max_depth=5,
heuristic="information_gain",
discrepancy=DiscrepancyRule(),
max_time=300,
)
clf.fit_anytime(X, y, callback=lambda error, seconds, status: print(seconds, error, status))
fit_anytime(X, y, callback) behaves like fit, and calls
callback(error, seconds, status) whenever a pass improves the tree. While a
rule still restricts the search, status is "budget_exhausted"; the last
call has "optimal" or "time_limit".
Custom error functions
DL8.5 is not tied to the misclassification error. It finds the tree that
minimises the sum of the errors of its leaves, for any error you define for a
leaf. Pass it as error_function, a function that receives the content of a
leaf and returns (error, prediction), where prediction is the index of the
predicted class in classes_.
What the function receives depends on error_function_input:
"class_counts"(the default): the number of rows of each class in the leaf, in the order ofclasses_. This covers any error that depends only on the class distribution, such as costs that differ per class."indices": the indices of the rows in the leaf. The error can then depend on anything you know about those rows: sample weights, another target, distances between rows, and so on.
With class counts, for instance, missing class 1 can cost five times more than missing class 0:
import numpy as np
from pytrees import DL85Classifier
costs = np.array([1.0, 5.0]) # cost of misclassifying a row of each class
def cost_sensitive(class_counts):
counts = np.asarray(class_counts, dtype=float)
# Cost of predicting each class: every row of another class is an error.
per_prediction = [(costs * counts).sum() - costs[k] * counts[k] for k in range(len(counts))]
best = int(np.argmin(per_prediction))
return per_prediction[best], best
clf = DL85Classifier(max_depth=3, error_function=cost_sensitive).fit(X, y)
With row indices, each row can carry its own weight:
classes, y_index = np.unique(y, return_inverse=True) # the encoding DL8.5 uses
weights = np.asarray(sample_weight, dtype=float)
def weighted_error(indices):
rows = np.asarray(indices, dtype=int)
# Weight of the rows each prediction would get wrong.
per_prediction = [weights[rows][y_index[rows] != k].sum() for k in range(len(classes))]
best = int(np.argmin(per_prediction))
return per_prediction[best], best
clf = DL85Classifier(
max_depth=3, error_function=weighted_error, error_function_input="indices"
).fit(X, y)
A few things change with a custom error:
- The similarity lower bound is turned off, because it assumes each row adds at most 1 to the error.
- With
"indices", the depth-2 solver is not used, because it works from class counts. The search is slower as a result. - The function is called from Rust for every leaf the search evaluates, often
thousands of times, so keep it cheap. An exception raised inside it stops
the search and is raised again by
fit. train_error_is the error your function measured, not the number of misclassified rows.
DL85Cluster uses the same mechanism with row indices to
learn clusterings.
Reference
G. Aglin, S. Nijssen and P. Schaus. Learning Optimal Decision Trees Using Caching Branch-and-Bound Search. AAAI 2020. The search rules are described in the papers listed on the search rules page.
Anytime search rules
The rules in pytrees.rules turn DL8.5 into an anytime search. Each rule
restricts a pass of the search to part of the search space, usually the part
the heuristic considers most promising. When a pass ends and a rule has cut
something, the search restarts with every rule relaxed (its budget widened),
reusing everything it cached. When a pass runs without any cut, the tree is
optimal.
This gives a good tree after the first, cheap passes, and the optimal tree if the search has time to finish. It is the framework of CA-DL8.5 (a complete anytime beam search), which generalises LDS-DL8.5 and Top-k-DL8.5.
Rules are passed to DL85Classifier or DL85Cluster by keyword, and several
can be combined:
from pytrees import DL85Classifier
from pytrees.rules import DiscrepancyRule, TopKRule
clf = DL85Classifier(
max_depth=5,
heuristic="information_gain", # the rules follow the heuristic's ranking
discrepancy=DiscrepancyRule(),
max_time=120,
)
Rules work with the feature ranking, so set a heuristic: with
heuristic="none" the ranking is just the column order. When a rule is given,
similarity_lb and dynamic_branching are turned off.
How budgets grow
Several rules have a budget that grows at each restart. step_strategy and
base say how:
step_strategy | Budgets |
|---|---|
"monotonic" | 0, base, 2·base, 3·base, … |
"exponential" | 1, base, base², base³, … |
"luby" | running sums of the Luby sequence 1, 1, 2, 1, 1, 2, 4, …, scaled by base |
Monotonic growth gives the finest control; exponential and Luby growth reach large budgets in fewer passes.
DiscrepancyRule
Limited discrepancy search (LDS-DL8.5). At each node, the features are ranked
by the heuristic, and choosing the feature of rank i costs i
discrepancies. A pass only explores trees whose total cost, summed along the
path from the root, is within the budget. The first pass therefore follows the
heuristic exactly, like a greedy learner, and each later pass allows more
deviations.
| Parameter | Default | Description |
|---|---|---|
initial_value | 0 | Budget of the first pass. |
limit | None | Largest budget; None grows it until the search is complete. |
step_strategy, base | "monotonic", 1 | How the budget grows. |
TopKRule
Top-k search. A pass with budget k only branches on the k + 1 best-ranked
features at each node, and k grows at each restart. Unlike the discrepancy
budget, it applies to every node separately rather than to a whole path.
| Parameter | Default | Description |
|---|---|---|
initial_value | 0 | k on the first pass. |
limit | None | Largest k; None grows it to every feature. |
step_strategy, base | "monotonic", 1 | How k grows. |
GainRule
Choosing a feature other than the best-ranked one loses the difference between their heuristic scores. A pass skips the paths whose accumulated loss exceeds a gap, and the gap widens at each restart. Where the discrepancy rule counts ranks, this rule measures how much worse the alternatives actually are.
| Parameter | Default | Description |
|---|---|---|
min_gain | 0.0 | Gap of the first pass. |
epsilon | 1e-4 | Step by which the gap widens. |
limit | 6.0 | Largest gap; usually about the maximum depth. |
step_strategy, base | "monotonic", 1 | How the gap grows. |
PurityRule
A pass does not split nodes whose share of correctly classified rows is at least a threshold; the threshold rises at each restart until every node may be split.
| Parameter | Default | Description |
|---|---|---|
min_purity | 0.0 | Threshold of the first pass. |
epsilon | 1e-4 | Amount the threshold rises by at each restart. |
RestartRule
Ends each pass after limit seconds and starts a new one, keeping the cache.
Combined with a heuristic ordering, this periodically sends the search back
to the most promising part of the space.
| Parameter | Default | Description |
|---|---|---|
limit | 1.0 | Seconds per pass. |
References
- H. Kiossou, P. Schaus, S. Nijssen and V. R. Houndji. Time Constrained DL8.5 Using Limited Discrepancy Search. ECML PKDD 2022.
- H. Kiossou and P. Schaus. A Generic Complete Anytime Beam Search for Optimal Decision Tree. IDA 2026.
LGDTClassifier
LGDTClassifier grows a tree top-down, like CART, but chooses each test with
a two-level lookahead. At each node it computes the best tree of depth 2 for
the rows in the node, keeps only its root test, and repeats on each child.
A single greedy split can miss tests that only pay off one level down (XOR is
the classic example); a depth-2 lookahead does not.
The lookahead uses the same specialised depth-2 solver as DL8.5, which counts the classes of every pair of features once and derives every depth-2 tree from those counts. That keeps LGDT fast enough for deep trees on large datasets, where an optimal search would not finish. Features must be binary.
from pytrees import LGDTClassifier
clf = LGDTClassifier(max_depth=8, min_sup=5).fit(X, y)
Parameters
| Parameter | Default | Description |
|---|---|---|
min_sup | 1 | Minimum number of training rows in each leaf. |
max_depth | 2 | Maximum depth of the tree. At depth 2 or less the tree is optimal (for criterion="error"). |
criterion | "error" | What the depth-2 lookahead optimises: "error" (misclassifications) or "information_gain". |
Fitted attributes
classes_, n_classes_, n_features_in_, tree_ and train_error_, as
described for DL85Classifier. statistics_ holds the error,
duration and sizes of the problem.
Reference
H. Kiossou, P. Schaus, S. Nijssen and G. Aglin. Efficient Lookahead Decision Trees. IDA 2024.
ConTreeClassifier
ConTreeClassifier finds the decision tree of at most max_depth levels with
the fewest training errors, directly on continuous features: no binarisation
is needed. Candidate thresholds lie halfway between consecutive distinct
values of each feature, and a row goes left when x[feature] <= threshold.
The exact search is ConTree, a depth-first branch-and-bound. Its key idea is that moving a threshold only moves rows from one child to the other, so the errors already computed for some thresholds bound the errors of their neighbours, and whole intervals of thresholds can be skipped at once.
from pytrees import ConTreeClassifier
clf = ConTreeClassifier(max_depth=3, min_sup=5, max_time=60).fit(X, y)
clf.status_ # "optimal", "time_limit", ...
Anytime search
The exact search completes the left subtree of a split before looking at the
right one, so when the time limit is short it can end with a poor tree. With
use_lds=True (or fit_anytime), the search runs in passes of growing
limited discrepancy budget instead: the first pass only follows the features
and thresholds ranked best by the Gini index, and each pass allows more
deviations from that ranking. A good tree is available after the first passes,
and a pass that completes without being cut proves the tree optimal.
clf = ConTreeClassifier(max_depth=5, sort_by_heuristic=True, split_selection="first")
clf.fit_anytime(X, y, callback=lambda error, seconds, status: print(seconds, error))
Set sort_by_heuristic=True with the anytime search, since the discrepancy
budget follows the Gini ranking.
Parameters
| Parameter | Default | Description |
|---|---|---|
max_depth | 3 | Maximum depth of the tree. The cost grows steeply with it. |
min_sup | 1 | Minimum number of training rows in each leaf. Interval pruning is only used when this is 1, so larger values make the search slower. |
max_error | None | Stop once a tree with at most this many errors is found. None searches for the optimum. |
max_time | 600.0 | Seconds before the search stops with the best tree found so far. |
max_gap | 0 | Accept a tree within this many errors of the optimum. Larger values prune more and finish sooner. |
split_selection | "mid" | Which candidate threshold of an interval is evaluated next: "mid" bisects the interval, "first" tries thresholds one at a time (in Gini order with sort_by_heuristic), "random" picks one at random. |
sort_by_heuristic | False | Try features and thresholds in Gini order. Usually finds a good tree sooner, which prunes more. |
fast_d2 | True | Solve depth-2 subtrees with a specialised solver. It is exact and much faster than the general search. |
use_lds | False | Use the anytime search in fit. |
budget_schedule | "diagonal" | How the anytime search widens its budgets between passes. It has two budgets, the discrepancy over features and the number of thresholds tried per feature. "diagonal" grows their sum by one per pass; "square" completes every budget pair up to (k, k) before moving to k + 1. |
random_state | None | Seed for split_selection="random". |
Fitted attributes
| Attribute | Description |
|---|---|
classes_, n_classes_ | The class labels and their number. |
n_features_in_ | Number of features seen in fit. |
tree_ | The fitted tree. |
train_error_ | Training misclassifications of the tree. |
status_ | "optimal", "time_limit", "budget_exhausted" (the anytime search had no larger budget to try) or "error_bound_reached" (max_gap was reached). Only "optimal" means the tree is proven best. |
statistics_ | duration, cache_size, cache_hits, general_solver_calls, specialized_solver_calls, n_samples, n_features, error. |
References
- C. E. Briţa, J. G. M. van der Linden and E. Demirović. Optimal Classification Trees for Continuous Feature Data Using Dynamic Programming with Branch-and-Bound. AAAI 2025. (The exact search.)
- H. Kiossou, P. Schaus and S. Nijssen. Anytime Optimal Decision Tree Learning with Continuous Features. ECML PKDD 2026. (The anytime search.)
DL85Cluster
DL85Cluster clusters rows with a decision tree: the tree splits the rows on
binary features, and each leaf is a cluster. DL8.5 searches for the tree whose
clusters are tightest, by default the one that minimises the sum of the
Euclidean distances from each row to the centroid of its cluster. The result
describes each cluster by a short rule, the path to its leaf.
import numpy as np
from pytrees import DL85Cluster
X = np.random.default_rng(0).integers(0, 2, size=(200, 8))
model = DL85Cluster(max_depth=2, min_sup=10).fit(X)
model.labels_ # the cluster of each row
model.n_clusters_ # at most 2 ** max_depth
print(model.to_dot())
The tree must split on binary features, but the distances can be measured in
other data: pass X_error to fit, with one row per row of X. This lets you
describe clusters of continuous measurements with interpretable binary
attributes:
model = DL85Cluster(max_depth=3, min_sup=20).fit(X_binary, X_error=X_measurements)
Parameters
min_sup, max_depth, max_error, max_time, always_sort, heuristic,
dynamic_branching and the search rules work as for
DL85Classifier; here min_sup is the minimum cluster size.
similarity_lb has no effect, because the similarity bound is only valid for
the misclassification error.
| Parameter | Default | Description |
|---|---|---|
error_function | None | error_function(indices) -> (error, value), called with the row indices of a candidate cluster. Replaces the distance to the centroid, so any measure of how tight a cluster is can be used. value is ignored. |
Fitted attributes
| Attribute | Description |
|---|---|
labels_ | Cluster of each training row, from 0 to n_clusters_ - 1. |
n_clusters_ | Number of clusters (leaves). |
tree_ | The fitted tree; each leaf’s value is its cluster number. |
train_error_ | Error of the clustering, as the error function measures it. |
status_ | "optimal" or "time_limit". |
predict(X) assigns new rows to clusters, and fit_predict(X) does both
steps at once.
The fitted tree
Every estimator stores its tree in tree_, an instance of
pytrees.tree.Tree. It uses the layout of scikit-learn’s own trees: one entry
per node in flat NumPy arrays, node 0 being the root.
| Array | Description |
|---|---|
children_left, children_right | Indices of the children of each node, -1 at a leaf. |
feature | Feature tested by each node, -1 at a leaf. |
threshold | Threshold of each test, NaN at a leaf. A row goes left when x[feature] <= threshold. On binary features the threshold is 0.5, so 0 goes left. |
value | What each leaf predicts, -1 at an internal node: an index into the estimator’s classes_, or a cluster number. |
error | Error of the subtree under each node. |
node_count, n_leaves and max_depth give the size of the tree.
Methods
The estimators expose these directly, after checking their input:
apply(X): the index of the leaf each row reaches.decision_path(X): a sparse matrix of shape(n_samples, node_count)marking the nodes each row passes through.to_dot(feature_names=None, class_names=None): the tree in Graphviz DOT format. Feature names default to the column names whenfitreceived a pandas DataFrame, and class names toclasses_.
clf.apply(X[:3]) # leaf index of each row
clf.decision_path(X[:3]).toarray()
open("tree.dot", "w").write(clf.to_dot(feature_names=names))
Render the DOT output with the dot program (dot -Tpng tree.dot -o tree.png)
or the graphviz Python package.
Command line tools
Two binaries give access to the algorithms without Python. Build them with:
cargo build --release -p dtrees-cli -p contree-cli
Both read datasets as text files with one row per line, values separated by
whitespace, and the class label (an integer from 0) in the first column.
Lines starting with # are ignored.
1 1 0 0 1 0 1
0 0 1 0 1 1 0
dtrees
dtrees runs DL8.5, LGDT and the depth-2 solver on binary features (every
value after the label must be 0 or 1). The common options come before the
subcommand:
dtrees --input data.txt --print-tree --print-stats dl85 --depth 3 --support 5 --timeout 60
dtrees --input data.txt --print-tree lgdt --depth 6
dtrees --input data.txt --print-tree d2 --depth 2
| Option | Description |
|---|---|
-i, --input | The dataset file. |
--print-tree, --print-stats | Print the tree and the search statistics. |
dl85
| Option | Default | Description |
|---|---|---|
-d, --depth | required | Maximum depth of the tree. |
-s, --support | 1 | Minimum number of rows in each leaf. |
-t, --timeout | none | Time limit in seconds. |
--heuristic | no-heuristic | Feature order: no-heuristic, gini-index, information-gain, weighted-entropy. |
--depth2-policy | enabled | Use the depth-2 solver for the last two levels. |
--lb | disabled | similarity enables the similarity lower bound. |
-b, --branching-policy | default | dynamic searches first the branch with the higher lower bound. |
--always-sort | Sort the features by the heuristic at every node, not only at the root. | |
--max-error | inf | Initial upper bound on the error. |
--print-config | Print the configuration. |
lgdt and d2
| Option | Default | Description |
|---|---|---|
-d, --depth | required for lgdt, 2 for d2 | Maximum depth (1 or 2 for d2). |
-s, --support | 1 | Minimum number of rows in each leaf. |
-o, --objective | error | What the tree (d2) or the lookahead (lgdt) optimises: error (misclassifications) or information-gain. |
dtrees <command> --help lists every option.
con-tree
con-tree runs ConTree on continuous features:
con-tree --input data.txt --depth 3 --sort-by-heuristic --print-tree --print-stats
con-tree --input data.txt --depth 5 --use-lds --sort-by-heuristic --time-limit 60 --print-stats
| Option | Default | Description |
|---|---|---|
-i, --input | required | The dataset file. |
-d, --depth | required | Maximum depth of the tree. |
-s, --support | 1 | Minimum number of rows in each leaf. |
-t, --time-limit | 600 | Time limit in seconds. |
--max-gap | 0 | Error gap to the optimum that is tolerated. |
--max-error | none | Initial upper bound on the error. |
--sort-by-heuristic | Explore features and thresholds in Gini order. | |
--split-selection-strategy | mid | mid, first or random; see ConTreeClassifier. |
--no-fast-d2 | Disable the depth-2 solver. | |
--use-lds | Use the anytime search. | |
--budget-schedule | diagonal | diagonal or square. |
--print-tree, --print-stats | Print the tree, and the statistics with the reason the search stopped. |
Rust crates
The Python package is a thin layer over two Rust libraries, which can be used on their own. They are not published on crates.io yet; depend on them from the repository:
[dependencies]
dtrees-rs = { git = "https://github.com/haroldks/pytrees-rs" }
contree-rs = { git = "https://github.com/haroldks/pytrees-rs" }
The full API documentation is published with this site;
cargo doc --open -p dtrees-rs -p contree-rs builds it locally.
dtrees-rs
Decision trees over binary features: DL8.5, the search rules that make it
anytime, LGDT and the depth-2 solvers. Data is loaded into a Cover, which
tracks the rows reaching the current node of the search.
#![allow(unused)]
fn main() {
use dtrees_rs::algorithms::greedy::factories::with_error_minimizer;
use dtrees_rs::algorithms::TreeSearchAlgorithm;
use dtrees_rs::reader::data_reader::DataReader;
use std::path::Path;
let mut cover = DataReader::default().read_file(Path::new("data.txt"))?;
let mut lgdt = with_error_minimizer().max_depth(4).min_support(5).build()?;
lgdt.fit(&mut cover)?;
println!("{}", lgdt.tree());
}
DL8.5 is assembled with DL85Builder, which takes the cache, the depth-2
solver, the error function and the heuristic as separate parts, and any number
of search rules:
#![allow(unused)]
fn main() {
use dtrees_rs::algorithms::common::errors::NativeError;
use dtrees_rs::algorithms::common::heuristics::InformationGain;
use dtrees_rs::algorithms::optimal::depth2::ErrorMinimizer;
use dtrees_rs::algorithms::optimal::dl85::DL85Builder;
use dtrees_rs::algorithms::optimal::rules::{DiscrepancyRule, Monotonic};
use dtrees_rs::algorithms::TreeSearchAlgorithm;
use dtrees_rs::caching::Trie;
let error_fn = Box::<NativeError>::default();
let mut dl85 = DL85Builder::default()
.max_depth(4)
.min_support(5)
.max_time(60.0)
.always_sort(true)
.add_search_rule(Box::new(DiscrepancyRule::new(usize::MAX, Box::<Monotonic>::default())))
.cache(Box::<Trie>::default())
.heuristic(Box::<InformationGain>::default())
.depth2_search(Box::new(ErrorMinimizer::new(error_fn.clone())))
.error_function(error_fn)
.build()?;
dl85.fit(&mut cover)?;
}
The examples/ directory of the crate has one program per search rule.
Custom error functions
DL8.5 minimises the sum of the errors of the leaves, and the error of a leaf
is whatever the ErrorWrapper passed to error_function computes. It
receives the class counts of the leaf, or its row ids when the builder is set
to node_exposed_data(NodeDataType::Tids), and returns
(error, predicted class):
#![allow(unused)]
fn main() {
use dtrees_rs::algorithms::common::errors::ErrorWrapper;
/// Misclassification cost that differs per class.
#[derive(Clone)]
struct CostSensitive {
costs: Vec<f64>,
}
impl ErrorWrapper for CostSensitive {
fn compute(&self, class_counts: &[usize]) -> (f64, f64) {
let total: f64 = class_counts.iter().zip(&self.costs).map(|(&n, c)| n as f64 * c).sum();
// Predict the class whose rows are the costliest to get wrong.
(0..class_counts.len())
.map(|k| (total - class_counts[k] as f64 * self.costs[k], k as f64))
.min_by(|a, b| a.0.total_cmp(&b.0))
.unwrap_or((0.0, 0.0))
}
}
let error_fn = Box::new(CostSensitive { costs: vec![1.0, 5.0] });
let mut dl85 = DL85Builder::default()
.max_depth(3)
.cache(Box::<Trie>::default())
.heuristic(Box::<NoHeuristic>::default())
.depth2_search(Box::new(ErrorMinimizer::new(error_fn.clone())))
.error_function(error_fn)
.build()?;
}
A plain function works too, through NativeError::new. Two options assume
the misclassification error: the similarity lower bound
(LowerBoundPolicy::Similarity) is only valid when each row adds at most 1
to the error, and the depth-2 solver needs class counts, so it is skipped
with row ids.
contree-rs
Optimal decision trees over continuous features: ConTree (exact) and
ConTreeLds (anytime).
#![allow(unused)]
fn main() {
use contree::algorithms::ConTree;
use contree::common::{PointSelector, SearchConfig, SearchStatus};
use contree::data::Dataset;
// Row-major values and labels 0..k.
let dataset = Dataset::from_rows(&values, &labels, n_features)?;
let config = SearchConfig::new(1, 3, 600.0, 0, usize::MAX, false, true, PointSelector::Mid);
let outcome = ConTree::with_config(config).fit(&dataset)?;
if outcome.status == SearchStatus::Optimal {
println!("{} training errors\n{}", outcome.error(), outcome.tree);
}
}
SearchConfig::new takes, in order: minimum support, maximum depth, time
limit, tolerated gap, initial error bound, Gini ordering, depth-2 solver and
point selector. ConTreeLds is built the same way; with_schedule chooses
its budget schedule and trajectory() returns every improvement as
(seconds, error).
The library is imported as contree (package contree-rs).
Publications
The algorithms in pytrees-rs were introduced in the following papers. If you use them in your work, please cite the relevant one.
Time Constrained DL8.5 Using Limited Discrepancy Search. H. Kiossou, P. Schaus, S. Nijssen and V. R. Houndji. ECML PKDD 2022, LNCS 13717, pp. 443-459. doi:10.1007/978-3-031-26419-1_27
Limited discrepancy search for DL8.5 (LDS-DL8.5), so that the search returns
good trees under a time limit. In pytrees: DL85Classifier with a
DiscrepancyRule.
Efficient Lookahead Decision Trees. H. Kiossou, P. Schaus, S. Nijssen and G. Aglin. IDA 2024, pp. 133-144. doi:10.1007/978-3-031-58553-1_11
LGDT, a top-down learner that chooses each test with an efficient depth-2
lookahead. In pytrees: LGDTClassifier.
A Generic Complete Anytime Beam Search for Optimal Decision Tree. H. Kiossou and P. Schaus. IDA 2026. doi:10.1007/978-3-032-23833-7_8, arXiv:2508.06064
CA-DL8.5, a framework that generalises LDS-DL8.5 and Top-k-DL8.5: rules
restrict each pass of the search and are relaxed at each restart. In pytrees:
the search rules of DL85Classifier.
Anytime Optimal Decision Tree Learning with Continuous Features. H. Kiossou, P. Schaus and S. Nijssen. ECML PKDD 2026. arXiv:2601.14765
An anytime version of ConTree based on limited discrepancy search. In
pytrees: ConTreeClassifier with use_lds=True or
fit_anytime.
Related work
- G. Aglin, S. Nijssen and P. Schaus. Learning Optimal Decision Trees Using Caching Branch-and-Bound Search. AAAI 2020. DL8.5; the original implementation is pydl8.5.
- E. Demirović, A. Lukina, E. Hebrard, J. Chan, J. Bailey, C. Leckie, K. Ramamohanarao and P. J. Stuckey. MurTree: Optimal Decision Trees via Dynamic Programming and Search. JMLR 23, 2022. The depth-2 solver used by DL8.5 and LGDT.
- C. E. Briţa, J. G. M. van der Linden and E. Demirović. Optimal Classification Trees for Continuous Feature Data Using Dynamic Programming with Branch-and-Bound. AAAI 2025. ConTree; the original implementation is ConSol-Lab/contree.