NODE: Neural Oblivious Decision Ensembles.
Implements the architecture of Popov et al. (ICLR 2020), Neural Oblivious Decision Ensembles for Deep Learning on Tabular Data. A NODE layer is an ensemble of oblivious decision trees -- trees that use the same split feature and threshold at every node of a given depth, so a tree of depth D is a lookup table with 2^D leaves -- made differentiable by replacing hard splits with \alpha-entmax. Layers are stacked DenseNet-style, each seeing the raw features plus every earlier layer's output.
This module also contains a dependency-free implementation of entmax15, the sparse alternative to softmax that gives the trees their near-hard splits and near-hard feature choices.
PyTorch is an optional dependency -- pip install 'tuiml[torch]'. Nothing in this module imports torch until it is called.
Classes
class algorithms.tabular_deep.node.NODEClassifier(_NODECore, _DeepTabularClassifierMixin, Classifier)
NODE: differentiable oblivious decision trees, stacked and dense.
Gradient boosting wins on tabular data because axis-aligned splits fit tables; deep learning wins because layers compose. NODE takes both: it replaces the hard split \mathbb{1}[x_f > b] with an entmax relaxation, so a whole ensemble of trees becomes one differentiable layer, and then stacks those layers DenseNet-style so later trees split on earlier trees' outputs -- something a boosted ensemble cannot do.
The trees are oblivious: every node at a given depth shares one split feature and one threshold. A depth-D tree is therefore a lookup table with 2^{D} leaves, evaluated for the whole batch with two einsums and no branching.
Overview
-
Each of
n_treestrees picks, per depth level, a split feature via
entmax15 over the input columns -- near one-hot, but differentiable.
- The chosen value is compared to a learned threshold and squashed by a
entmax15 over [go right, go left] gives a soft, often exactly-hard, decision.
-
The outer product of the
tree_depthdecisions gives a distribution
- Layers are concatenated to their own input (dense connectivity) and the
Theory
For tree t at depth level d, the split score is
and the leaf-membership weight of leaf \ell = (c_1, \dots, c_D) is the product of the per-level choices
The layer output is \sum_{\ell} w_{t\ell}(x) R_{t\ell} with a learned response table R. Because entmax returns exact zeros, most leaves receive weight zero: the relaxation is soft enough to train and sharp enough to behave like a tree.
Parameters
n_layers
n_trees
tree_depth
learning_rate
weight_decay
batch_size
n_epochs
early_stopping
validation_fraction
early_stopping is enabled.
patience
device
"auto" trades reproducibility for speed.
random_state
Attributes
classes_
fit.
network_
feature_mean_, feature_scale_
loss_curve_
n_iter_
n_features_in_
fit.
Notes
Requires PyTorch. Install with pip install 'tuiml[torch]'. The class constructs and introspects without torch; fit raises ImportError naming the install command.
Complexity. Per epoch, O(n L T (m D + 2^{D} k)) for L layers, T trees, depth D and k outputs -- exponential in the depth, linear in everything else. Memory is O(b T 2^{D}) for the leaf weights.
When to use. NODE is the deep model to try when the problem looks like one gradient boosting would win: axis-aligned structure, thresholds, moderate feature counts. It keeps that inductive bias while remaining differentiable, so it can be trained jointly with other neural components -- which is the reason to prefer it over an actual boosted ensemble.
References
See Also
Constructing and inspecting a model needs no torch:
>>> from tuiml.algorithms.tabular_deep import NODEClassifier
>>> model = NODEClassifier(n_trees=16, tree_depth=3, random_state=0)
>>> model.tree_depth
3
>>> NODEClassifier.get_parameter_schema()["n_trees"]["default"]
32
Fitting requires pip install 'tuiml[torch]'; the example below is a
no-op on an install without it:
>>> import numpy as np
>>> from tuiml.utils.torch_backend import has_torch
>>> rng = np.random.default_rng(0)
>>> X = rng.normal(size=(300, 4))
>>> y = ((X[:, 0] > 0) ^ (X[:, 1] > 0)).astype(int)
>>> if has_torch():
... model = NODEClassifier(n_epochs=150, random_state=0).fit(X, y)
... print(float(model.score(X, y)) > 0.85)
... else:
... print(True)
True
class algorithms.tabular_deep.node.NODERegressor(_NODECore, _DeepTabularRegressorMixin, Regressor)
NODE for regression: differentiable oblivious tree ensembles.
NODEClassifier. The architecture is unchanged -- entmax-relaxed oblivious trees, densely stacked -- and only the objective changes: a single response dimension trained with mean squared error against a standardised target.Overview
- Standardise features and target.
-
Each tree picks split features with
entmax15, compares them to
- Layers concatenate their outputs to their input, so later trees can
- Average every tree's response and undo the target standardisation.
Theory
The prediction is an average over all LT trees of soft leaf lookups,
trained by minimising \|\hat{y} - \tilde{y}\|^2 on the standardised target. Because w is piecewise-smooth rather than piecewise-constant, the fitted surface is continuous -- unlike a tree ensemble's staircase, which is often the practical difference on smooth targets.
Parameters
n_layers
n_trees
tree_depth
learning_rate
weight_decay
batch_size
n_epochs
early_stopping
validation_fraction
early_stopping is enabled.
patience
device
"auto" trades reproducibility for speed.
random_state
Attributes
network_
target_mean_, target_scale_
feature_mean_, feature_scale_
loss_curve_
n_iter_
n_features_in_
fit.
Notes
Requires PyTorch. Install with pip install 'tuiml[torch]'.
Complexity. O(n L T (m D + 2^{D})) per epoch, exponential in tree_depth and linear in everything else.
When to use. Reach for NODE when the target is a smooth function of threshold-like structure: it keeps the axis-aligned bias of a boosted ensemble but produces a continuous surface, and it can be trained jointly with other neural components.
References
See Also
>>> from tuiml.algorithms.tabular_deep import NODERegressor
>>> model = NODERegressor(n_layers=2, n_trees=16)
>>> model.n_layers
2
>>> "tree" in NODERegressor.get_parameter_schema()["tree_depth"]["description"]
True
Fitting requires pip install 'tuiml[torch]'; the example below is a
no-op on an install without it:
>>> import numpy as np
>>> from tuiml.utils.torch_backend import has_torch
>>> rng = np.random.default_rng(0)
>>> X = rng.normal(size=(300, 4))
>>> y = np.sin(X[:, 0]) * X[:, 1]
>>> if has_torch():
... model = NODERegressor(n_epochs=300, random_state=0).fit(X, y)
... print(float(model.score(X, y)) > 0.8)
... else:
... print(True)
True
Functions
Project logits onto the simplex with 1.5-entmax: a sparse softmax.
Parameters
inputs
dim.
dim
Returns
probabilities
inputs. Non-negative and summing to one along dim, with exact zeros outside the support.
Notes
Solves
where the threshold \tau is chosen so the result sums to one. The exact algorithm sorts the scores and walks the candidate support sizes, costing O(k \log k) for k coordinates.
The backward pass uses the closed form rather than autograd through the sort. That is not an optimisation: at the edge of the support the threshold search evaluates \sqrt{0}, whose derivative is infinite, so differentiating the search itself produces NaN weights a few hundred steps into training. The exact Jacobian-vector product is
for upstream gradient g, which is finite everywhere.
Requires PyTorch: pip install 'tuiml[torch]'.
References
>>> from tuiml.algorithms.tabular_deep.node import entmax15
>>> from tuiml.utils.torch_backend import has_torch
>>> if has_torch():
... import torch
... p = entmax15(torch.tensor([[1.0, 2.0, 9.0]]))
... print(bool(torch.allclose(p.sum(-1), torch.ones(1))), bool(p[0, 0] == 0))
... else:
... print(True, True)
True True