Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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:

EstimatorFeaturesWhat it learns
DL85ClassifierbinaryThe optimal tree of a given depth (DL8.5)
LGDTClassifierbinaryA tree grown top-down whose tests are chosen with a depth-2 lookahead (LGDT)
ConTreeClassifiercontinuousThe optimal tree of a given depth (ConTree)
DL85ClusterbinaryA 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, with status_ 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 a fit_anytime method 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

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.

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.

EstimatorTaskFeaturesSearch
DL85Classifierclassificationbinaryoptimal, optionally anytime
LGDTClassifierclassificationbinarygreedy with a depth-2 lookahead
ConTreeClassifierclassificationcontinuousoptimal, optionally anytime
DL85Clusterclusteringbinaryoptimal

Some behaviour is shared by all of them:

  • Labels. y can hold any labels np.unique accepts (integers, strings, and so on). classes_ lists them, and predict returns them.
  • Binary features. DL8.5, LGDT and DL85Cluster only accept 0 and 1 in X and raise a ValueError otherwise, both in fit and in predict. Use a Binarizer, a KBinsDiscretizer with one-hot encoding, or your own thresholds to prepare the data.
  • The fitted tree. tree_ is a pytrees.tree.Tree with the same layout as scikit-learn’s. apply, decision_path and to_dot work on it for every estimator.
  • Search results. train_error_ is the training error of the tree, status_ says why the search stopped, and statistics_ 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

ParameterDefaultDescription
min_sup1Minimum number of training rows in each leaf.
max_depth1Maximum depth of the tree.
max_errorNoneStop as soon as a tree with at most this error is found. None searches for the optimum.
max_time600.0Seconds before the search stops with the best tree found so far.
always_sortTrueSort 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_d2TrueSolve 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_lbTrueBound 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_branchingTrueFor 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, purityNoneSearch rules that make the search anytime.
error_functionNoneA custom error for the leaves, see below.

Fitted attributes

AttributeDescription
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.

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 of classes_. 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_strategyBudgets
"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.

ParameterDefaultDescription
initial_value0Budget of the first pass.
limitNoneLargest budget; None grows it until the search is complete.
step_strategy, base"monotonic", 1How 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.

ParameterDefaultDescription
initial_value0k on the first pass.
limitNoneLargest k; None grows it to every feature.
step_strategy, base"monotonic", 1How 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.

ParameterDefaultDescription
min_gain0.0Gap of the first pass.
epsilon1e-4Step by which the gap widens.
limit6.0Largest gap; usually about the maximum depth.
step_strategy, base"monotonic", 1How 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.

ParameterDefaultDescription
min_purity0.0Threshold of the first pass.
epsilon1e-4Amount 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.

ParameterDefaultDescription
limit1.0Seconds 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

ParameterDefaultDescription
min_sup1Minimum number of training rows in each leaf.
max_depth2Maximum 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

ParameterDefaultDescription
max_depth3Maximum depth of the tree. The cost grows steeply with it.
min_sup1Minimum number of training rows in each leaf. Interval pruning is only used when this is 1, so larger values make the search slower.
max_errorNoneStop once a tree with at most this many errors is found. None searches for the optimum.
max_time600.0Seconds before the search stops with the best tree found so far.
max_gap0Accept 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_heuristicFalseTry features and thresholds in Gini order. Usually finds a good tree sooner, which prunes more.
fast_d2TrueSolve depth-2 subtrees with a specialised solver. It is exact and much faster than the general search.
use_ldsFalseUse 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_stateNoneSeed for split_selection="random".

Fitted attributes

AttributeDescription
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.

ParameterDefaultDescription
error_functionNoneerror_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

AttributeDescription
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.

ArrayDescription
children_left, children_rightIndices of the children of each node, -1 at a leaf.
featureFeature tested by each node, -1 at a leaf.
thresholdThreshold 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.
valueWhat each leaf predicts, -1 at an internal node: an index into the estimator’s classes_, or a cluster number.
errorError 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 when fit received a pandas DataFrame, and class names to classes_.
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
OptionDescription
-i, --inputThe dataset file.
--print-tree, --print-statsPrint the tree and the search statistics.

dl85

OptionDefaultDescription
-d, --depthrequiredMaximum depth of the tree.
-s, --support1Minimum number of rows in each leaf.
-t, --timeoutnoneTime limit in seconds.
--heuristicno-heuristicFeature order: no-heuristic, gini-index, information-gain, weighted-entropy.
--depth2-policyenabledUse the depth-2 solver for the last two levels.
--lbdisabledsimilarity enables the similarity lower bound.
-b, --branching-policydefaultdynamic searches first the branch with the higher lower bound.
--always-sortSort the features by the heuristic at every node, not only at the root.
--max-errorinfInitial upper bound on the error.
--print-configPrint the configuration.

lgdt and d2

OptionDefaultDescription
-d, --depthrequired for lgdt, 2 for d2Maximum depth (1 or 2 for d2).
-s, --support1Minimum number of rows in each leaf.
-o, --objectiveerrorWhat 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
OptionDefaultDescription
-i, --inputrequiredThe dataset file.
-d, --depthrequiredMaximum depth of the tree.
-s, --support1Minimum number of rows in each leaf.
-t, --time-limit600Time limit in seconds.
--max-gap0Error gap to the optimum that is tolerated.
--max-errornoneInitial upper bound on the error.
--sort-by-heuristicExplore features and thresholds in Gini order.
--split-selection-strategymidmid, first or random; see ConTreeClassifier.
--no-fast-d2Disable the depth-2 solver.
--use-ldsUse the anytime search.
--budget-schedulediagonaldiagonal or square.
--print-tree, --print-statsPrint 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.

  • 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.