Export tree-based imodels models as fitted scikit-learn estimators.
to_sklearn() turns a fitted tree-based model into the scikit-learn object with the
same predictions: a DecisionTreeClassifier / DecisionTreeRegressor for a
single tree, a RandomForestClassifier / RandomForestRegressor for an
iterative random forest, and a list of DecisionTreeRegressor (one per tree,
added up) for FIGS. Anything that reads scikit-learn trees then works on imodels
trees too: imodels.viz draws tree-based models only through this export, and
dtreeviz can draw the result directly (see shadow_tree()).
The exported trees keep each node's sample counts. Pass the training data as
X to recount them, which matters for TaoTree (whose splits are rewritten after
the counts were taken) and C4.5 (which stores no counts).
scikit-learn compares inputs in float32, while some imodels trees compare in float64. Thresholds are rounded so every row is routed the same way, except rows within float32 rounding (about 1e-7 relative) of a threshold.
Expand source code
"""Export tree-based imodels models as fitted scikit-learn estimators.
`to_sklearn` turns a fitted tree-based model into the scikit-learn object with the
same predictions: a ``DecisionTreeClassifier`` / ``DecisionTreeRegressor`` for a
single tree, a ``RandomForestClassifier`` / ``RandomForestRegressor`` for an
iterative random forest, and a list of ``DecisionTreeRegressor`` (one per tree,
added up) for FIGS. Anything that reads scikit-learn trees then works on imodels
trees too: `imodels.viz` draws tree-based models only through this export, and
dtreeviz can draw the result directly (see `imodels.shadow_tree`).
The exported trees keep each node's sample counts. Pass the training data as
``X`` to recount them, which matters for TaoTree (whose splits are rewritten after
the counts were taken) and C4.5 (which stores no counts).
scikit-learn compares inputs in float32, while some imodels trees compare in
float64. Thresholds are rounded so every row is routed the same way, except rows
within float32 rounding (about 1e-7 relative) of a threshold.
"""
import copy
import numpy as np
__all__ = ["to_sklearn", "build_sklearn_tree"]
def build_sklearn_tree(children_left, children_right, feature, threshold, value, *,
classes=None, n_node_samples=None, impurity=None, missing_go_to_left=None,
n_features_in=None, feature_names_in=None):
"""A fitted scikit-learn decision tree from node arrays.
Parameters
----------
children_left, children_right : array of int, -1 at leaves
feature, threshold : arrays; a node sends x left when ``x[feature] <= threshold``
(compared in float32, as scikit-learn does)
value : array (n_nodes, n_classes) of class probabilities (classifier) or
(n_nodes, n_outputs) of predictions (regressor)
classes : class labels for a classifier; None builds a regressor
n_node_samples : samples reaching each node; defaults to the number of leaves below it
impurity, missing_go_to_left : optional per-node arrays (impurity defaults to NaN, unknown;
missing values go right by default)
n_features_in, feature_names_in : the input features
"""
from sklearn import __version__
from sklearn.tree import DecisionTreeClassifier, DecisionTreeRegressor
from sklearn.tree._tree import Tree
left = np.asarray(children_left, dtype=np.intp)
right = np.asarray(children_right, dtype=np.intp)
n = len(left)
value = np.asarray(value, dtype=float).reshape(n, -1)
is_clf = classes is not None
if n_node_samples is None:
n_node_samples = np.zeros(n)
for i in reversed(range(n)): # children come after their parent
n_node_samples[i] = 1 if left[i] < 0 else n_node_samples[left[i]] + n_node_samples[right[i]]
depth = np.zeros(n, dtype=int)
for i in range(n):
if left[i] >= 0:
depth[left[i]] = depth[right[i]] = depth[i] + 1
if n_features_in is None:
n_features_in = int(np.max(feature)) + 1 if np.any(left >= 0) else 1
if is_clf:
n_outputs, n_classes = 1, np.array([value.shape[1]], dtype=np.intp)
values = value.reshape(n, 1, -1)
else:
n_outputs, n_classes = value.shape[1], np.ones(value.shape[1], dtype=np.intp)
values = value.reshape(n, -1, 1)
tree = Tree(int(n_features_in), n_classes, n_outputs)
nodes = np.zeros(n, dtype=tree.__getstate__()["nodes"].dtype)
nodes["left_child"], nodes["right_child"] = left, right
nodes["feature"] = np.where(left >= 0, np.asarray(feature), -2)
nodes["threshold"] = np.where(left >= 0, np.asarray(threshold, dtype=float), -2.0)
# unknown impurities stay NaN rather than 0, so nothing reads them as pure nodes
nodes["impurity"] = np.nan if impurity is None else np.asarray(impurity, dtype=float)
nodes["n_node_samples"] = np.asarray(n_node_samples).astype(np.intp)
nodes["weighted_n_node_samples"] = np.asarray(n_node_samples, dtype=float)
if "missing_go_to_left" in nodes.dtype.names:
nodes["missing_go_to_left"] = 0 if missing_go_to_left is None else np.asarray(missing_go_to_left)
tree.__setstate__({"max_depth": int(depth.max()), "node_count": n, "nodes": nodes,
"values": np.ascontiguousarray(values)})
est = DecisionTreeClassifier() if is_clf else DecisionTreeRegressor()
est.tree_ = tree
est.n_outputs_ = n_outputs
est.n_features_in_ = int(n_features_in)
est.max_features_ = int(n_features_in)
est._sklearn_version = __version__
if is_clf:
est.classes_ = np.asarray(classes)
est.n_classes_ = len(classes)
if feature_names_in is not None:
est.feature_names_in_ = np.asarray(feature_names_in, dtype=object)
return est
def _f32_le(t):
"""float32 threshold for ``x <= t``: every x <= t goes left."""
return float(np.float32(t))
def _f32_lt(t):
"""float32 threshold for ``x < t``: x equal to t goes right (float32(t) itself must go right)."""
return float(np.nextafter(np.float32(t), np.float32(-np.inf)))
def _state(est):
st = est.tree_.__getstate__()
st["nodes"] = st["nodes"].copy()
return st
def recount(est, X):
"""Set each node's sample counts of a scikit-learn tree to the rows of X reaching it (in place)."""
import warnings
with warnings.catch_warnings():
warnings.simplefilter("ignore", UserWarning) # fitted with or without feature names
counts = np.asarray(est.decision_path(X).sum(axis=0)).ravel()
st = _state(est)
st["nodes"]["n_node_samples"] = counts.astype(np.intp)
st["nodes"]["weighted_n_node_samples"] = counts.astype(float)
est.tree_.__setstate__(st)
return est
# ---------------------------------------------------------------- per model
def _figs(m):
"""FIGS: one regression tree per FIGS tree, holding the values the model adds up."""
names = getattr(m, "feature_names_in_", None)
out = []
for root in m.trees_:
rows = []
def add(nd):
me = len(rows)
rows.append(None)
if nd.left is None or nd.right is None:
rows[me] = (-1, -1, -2, -2.0, np.asarray(nd.value, dtype=float).ravel(), nd)
else:
lft = add(nd.left)
rgt = add(nd.right)
val = None if nd.value is None else np.asarray(nd.value, dtype=float).ravel()
if val is None or val.size != rows[lft][4].size or not np.all(np.isfinite(val)):
# FIGS leaves the root of every tree after the first without a value; inner values
# never reach a prediction, so use the children's sample-weighted average
wl, wr = (float(getattr(c, "n_samples_", 0) or 0) for c in (nd.left, nd.right))
wl, wr = (wl, wr) if wl + wr > 0 else (1.0, 1.0)
val = (rows[lft][4] * wl + rows[rgt][4] * wr) / (wl + wr)
rows[me] = (lft, rgt, int(nd.feature), _f32_le(nd.threshold), val, nd)
return me
add(root)
l, r, f, t, v, nds = zip(*rows)
out.append(build_sklearn_tree(
l, r, f, t, np.array(v), n_node_samples=[int(getattr(nd, "n_samples_", 0) or 0) for nd in nds],
impurity=[getattr(nd, "impurity", np.nan) for nd in nds], n_features_in=m.n_features_in_,
feature_names_in=names))
return out
def _c45(m, X):
"""C4.5: leaves keep P(class 1) (binary, including after shrinkage) or their label (multiclass)."""
xml_names = list(m.feature_names)
to_name = getattr(m, "xml_name_to_feature_name_", {})
K = len(m.classes_)
rows = []
def add(el):
me = len(rows)
rows.append(None)
kids = [c for c in el.childNodes if c.nodeType == c.ELEMENT_NODE]
if not kids:
v = float(el.firstChild.nodeValue)
rows[me] = (-1, -1, -2, -2.0, np.array([1 - v, v]) if K == 2 else np.eye(K)[int(v)])
return me
flags = {c.getAttribute("flag"): c for c in kids}
if set(flags) != {"l", "r"}:
raise NotImplementedError("C4.5 trees with categorical (multiway) splits cannot be exported.")
lc, rc = flags["l"], flags["r"]
f = xml_names.index(lc.tagName) if lc.tagName in xml_names else \
[to_name.get(nm, nm) for nm in xml_names].index(to_name.get(lc.tagName, lc.tagName))
lft, rgt = add(lc), add(rc) # 'l' holds x < threshold
rows[me] = (lft, rgt, f, _f32_lt(float(lc.getAttribute("feature"))), None)
return me
add(m.root)
n = len(rows)
left = np.array([r[0] for r in rows])
right = np.array([r[1] for r in rows])
leaves = np.zeros(n)
value = np.zeros((n, K))
for i in reversed(range(n)): # inner nodes: the average of their leaves
if left[i] < 0:
value[i], leaves[i] = rows[i][4], 1
else:
leaves[i] = leaves[left[i]] + leaves[right[i]]
value[i] = (value[left[i]] * leaves[left[i]] + value[right[i]] * leaves[right[i]]) / leaves[i]
return build_sklearn_tree(left, right, [r[2] for r in rows], [r[3] for r in rows], value,
classes=m.classes_, n_features_in=len(xml_names))
def _irf(m):
"""Iterative random forest: its weighted trees as a scikit-learn random forest."""
from sklearn.ensemble import RandomForestClassifier, RandomForestRegressor
forest = m.forest_
clf = getattr(forest, "task", "classification") != "regression"
classes = np.arange(forest.n_classes_) if clf else None
trees = []
for t in forest.estimators_:
if clf:
val = np.asarray(t.value_, dtype=float).reshape(len(t.value_), -1)
else:
val = np.asarray(getattr(t, "leaf_prediction_", t.value_), dtype=float).reshape(len(t.value_), -1)
trees.append(build_sklearn_tree(
t.children_left_, t.children_right_, t.feature_, t.threshold_, val, classes=classes,
n_node_samples=t.n_node_samples_, impurity=t.impurity_, n_features_in=m.n_features_in_))
rf = RandomForestClassifier(n_estimators=len(trees)) if clf else RandomForestRegressor(n_estimators=len(trees))
rf.estimators_ = trees
rf.estimator_ = trees[0]
rf.n_outputs_ = 1
rf.n_features_in_ = m.n_features_in_
if clf:
rf.classes_ = np.asarray(m.classes_)
rf.n_classes_ = len(m.classes_)
for t in trees:
t.classes_ = rf.classes_
if hasattr(m, "feature_names_in_"):
rf.feature_names_in_ = m.feature_names_in_
return rf
def to_sklearn(model, X=None):
"""The scikit-learn equivalent of a fitted tree-based imodels model.
Parameters
----------
model
A fitted tree-based model: GreedyTree, DecisionTreeCCP, HSTree (and its CV
and CCP variants), TaoTree, FastSmallTree, C4.5 (and HSC45), FIGS (and
FIGSCV), IRF, or a scikit-learn tree.
X : array-like, optional
Training data. When given, every node's sample counts are recounted on it.
Returns
-------
DecisionTreeClassifier or DecisionTreeRegressor
For single trees. ``predict`` / ``predict_proba`` match the model's.
RandomForestClassifier or RandomForestRegressor
For IRF.
list of DecisionTreeRegressor
For FIGS, one per tree. Their predictions add up to the model's raw score:
the prediction for a regressor, and for a classifier the per-class scores
whose softmax is ``predict_proba`` (``sum(t.predict(X) for t in trees)``).
Examples
--------
>>> from imodels import FastSmallTreeClassifier, to_sklearn # doctest: +SKIP
>>> tree = to_sklearn(FastSmallTreeClassifier().fit(X, y)) # doctest: +SKIP
>>> sklearn.tree.plot_tree(tree) # doctest: +SKIP
"""
from imodels.util.model_trees import is_sklearn_tree
name = type(model).__name__
if name in ("FIGSClassifierCV", "FIGSRegressorCV"):
return to_sklearn(model.figs, X)
if hasattr(model, "trees_"): # FIGS
if getattr(model, "_encoder", None) is not None:
raise NotImplementedError("FIGS with categorical_features cannot be exported: its splits are on encoded columns.")
return _figs(model)
if name in ("IRFClassifier", "IRFRegressor"):
return _irf(model)
if name in ("HSC45TreeClassifier", "HSC45TreeClassifierCV"):
model = model.estimator_
name = type(model).__name__
if name == "C45TreeClassifier":
est = _c45(model, X)
elif name == "FastSmallTreeClassifier":
est = copy.deepcopy(model.estimator_)
# tree_.value is a cost-weighted device for argmax; predict_proba reads the leaf frequencies
st = _state(est)
st["values"] = np.ascontiguousarray(np.asarray(model._node_proba_, dtype=float)[:, None, :])
est.tree_.__setstate__(st)
elif is_sklearn_tree(model): # sklearn trees and GreedyTree
est = copy.deepcopy(model)
else:
inner = getattr(model, "model", None) if name.startswith("TaoTree") else getattr(model, "estimator_", None)
if inner is None or not (is_sklearn_tree(inner) or hasattr(inner, "estimators_")):
raise TypeError(f"{name} is not a fitted tree-based model that can be exported to scikit-learn.")
est = copy.deepcopy(inner) # TaoTree, DecisionTreeCCP, HSTree (shrunk values live in estimator_)
if not is_sklearn_tree(est): # hierarchical shrinkage of a forest
if X is not None:
for e in est.estimators_:
recount(getattr(e, "estimator_", e), X)
return est
if X is not None:
recount(est, np.asarray(X, dtype=float) if not hasattr(X, "columns") else X)
return est
Functions
def build_sklearn_tree(children_left, children_right, feature, threshold, value, *, classes=None, n_node_samples=None, impurity=None, missing_go_to_left=None, n_features_in=None, feature_names_in=None)-
A fitted scikit-learn decision tree from node arrays.
Parameters
children_left,children_right:arrayofint, -1 at leavesfeature,threshold: `arrays; a node sends x left when ``x[feature] <= threshold```- (compared in float32, as scikit-learn does)
value:array (n_nodes, n_classes)ofclass probabilities (classifier) or- (n_nodes, n_outputs) of predictions (regressor)
classes:class labels for a classifier; None builds a regressorn_node_samples:samples reaching each node; defaults to the numberofleaves below itimpurity,missing_go_to_left:optional per-node arrays (impurity defaults to NaN, unknown;- missing values go right by default)
n_features_in,feature_names_in:the input features
Expand source code
def build_sklearn_tree(children_left, children_right, feature, threshold, value, *, classes=None, n_node_samples=None, impurity=None, missing_go_to_left=None, n_features_in=None, feature_names_in=None): """A fitted scikit-learn decision tree from node arrays. Parameters ---------- children_left, children_right : array of int, -1 at leaves feature, threshold : arrays; a node sends x left when ``x[feature] <= threshold`` (compared in float32, as scikit-learn does) value : array (n_nodes, n_classes) of class probabilities (classifier) or (n_nodes, n_outputs) of predictions (regressor) classes : class labels for a classifier; None builds a regressor n_node_samples : samples reaching each node; defaults to the number of leaves below it impurity, missing_go_to_left : optional per-node arrays (impurity defaults to NaN, unknown; missing values go right by default) n_features_in, feature_names_in : the input features """ from sklearn import __version__ from sklearn.tree import DecisionTreeClassifier, DecisionTreeRegressor from sklearn.tree._tree import Tree left = np.asarray(children_left, dtype=np.intp) right = np.asarray(children_right, dtype=np.intp) n = len(left) value = np.asarray(value, dtype=float).reshape(n, -1) is_clf = classes is not None if n_node_samples is None: n_node_samples = np.zeros(n) for i in reversed(range(n)): # children come after their parent n_node_samples[i] = 1 if left[i] < 0 else n_node_samples[left[i]] + n_node_samples[right[i]] depth = np.zeros(n, dtype=int) for i in range(n): if left[i] >= 0: depth[left[i]] = depth[right[i]] = depth[i] + 1 if n_features_in is None: n_features_in = int(np.max(feature)) + 1 if np.any(left >= 0) else 1 if is_clf: n_outputs, n_classes = 1, np.array([value.shape[1]], dtype=np.intp) values = value.reshape(n, 1, -1) else: n_outputs, n_classes = value.shape[1], np.ones(value.shape[1], dtype=np.intp) values = value.reshape(n, -1, 1) tree = Tree(int(n_features_in), n_classes, n_outputs) nodes = np.zeros(n, dtype=tree.__getstate__()["nodes"].dtype) nodes["left_child"], nodes["right_child"] = left, right nodes["feature"] = np.where(left >= 0, np.asarray(feature), -2) nodes["threshold"] = np.where(left >= 0, np.asarray(threshold, dtype=float), -2.0) # unknown impurities stay NaN rather than 0, so nothing reads them as pure nodes nodes["impurity"] = np.nan if impurity is None else np.asarray(impurity, dtype=float) nodes["n_node_samples"] = np.asarray(n_node_samples).astype(np.intp) nodes["weighted_n_node_samples"] = np.asarray(n_node_samples, dtype=float) if "missing_go_to_left" in nodes.dtype.names: nodes["missing_go_to_left"] = 0 if missing_go_to_left is None else np.asarray(missing_go_to_left) tree.__setstate__({"max_depth": int(depth.max()), "node_count": n, "nodes": nodes, "values": np.ascontiguousarray(values)}) est = DecisionTreeClassifier() if is_clf else DecisionTreeRegressor() est.tree_ = tree est.n_outputs_ = n_outputs est.n_features_in_ = int(n_features_in) est.max_features_ = int(n_features_in) est._sklearn_version = __version__ if is_clf: est.classes_ = np.asarray(classes) est.n_classes_ = len(classes) if feature_names_in is not None: est.feature_names_in_ = np.asarray(feature_names_in, dtype=object) return est def to_sklearn(model, X=None)-
The scikit-learn equivalent of a fitted tree-based imodels model.
Parameters
model- A fitted tree-based model: GreedyTree, DecisionTreeCCP, HSTree (and its CV and CCP variants), TaoTree, FastSmallTree, C4.5 (and HSC45), FIGS (and FIGSCV), IRF, or a scikit-learn tree.
X:array-like, optional- Training data. When given, every node's sample counts are recounted on it.
Returns
DecisionTreeClassifierorDecisionTreeRegressor- For single trees.
predict/predict_probamatch the model's. RandomForestClassifierorRandomForestRegressor- For IRF.
listofDecisionTreeRegressor- For FIGS, one per tree. Their predictions add up to the model's raw score:
the prediction for a regressor, and for a classifier the per-class scores
whose softmax is
predict_proba(sum(t.predict(X) for t in trees)).
Examples
>>> from imodels import FastSmallTreeClassifier, to_sklearn # doctest: +SKIP >>> tree = to_sklearn(FastSmallTreeClassifier().fit(X, y)) # doctest: +SKIP >>> sklearn.tree.plot_tree(tree) # doctest: +SKIPExpand source code
def to_sklearn(model, X=None): """The scikit-learn equivalent of a fitted tree-based imodels model. Parameters ---------- model A fitted tree-based model: GreedyTree, DecisionTreeCCP, HSTree (and its CV and CCP variants), TaoTree, FastSmallTree, C4.5 (and HSC45), FIGS (and FIGSCV), IRF, or a scikit-learn tree. X : array-like, optional Training data. When given, every node's sample counts are recounted on it. Returns ------- DecisionTreeClassifier or DecisionTreeRegressor For single trees. ``predict`` / ``predict_proba`` match the model's. RandomForestClassifier or RandomForestRegressor For IRF. list of DecisionTreeRegressor For FIGS, one per tree. Their predictions add up to the model's raw score: the prediction for a regressor, and for a classifier the per-class scores whose softmax is ``predict_proba`` (``sum(t.predict(X) for t in trees)``). Examples -------- >>> from imodels import FastSmallTreeClassifier, to_sklearn # doctest: +SKIP >>> tree = to_sklearn(FastSmallTreeClassifier().fit(X, y)) # doctest: +SKIP >>> sklearn.tree.plot_tree(tree) # doctest: +SKIP """ from imodels.util.model_trees import is_sklearn_tree name = type(model).__name__ if name in ("FIGSClassifierCV", "FIGSRegressorCV"): return to_sklearn(model.figs, X) if hasattr(model, "trees_"): # FIGS if getattr(model, "_encoder", None) is not None: raise NotImplementedError("FIGS with categorical_features cannot be exported: its splits are on encoded columns.") return _figs(model) if name in ("IRFClassifier", "IRFRegressor"): return _irf(model) if name in ("HSC45TreeClassifier", "HSC45TreeClassifierCV"): model = model.estimator_ name = type(model).__name__ if name == "C45TreeClassifier": est = _c45(model, X) elif name == "FastSmallTreeClassifier": est = copy.deepcopy(model.estimator_) # tree_.value is a cost-weighted device for argmax; predict_proba reads the leaf frequencies st = _state(est) st["values"] = np.ascontiguousarray(np.asarray(model._node_proba_, dtype=float)[:, None, :]) est.tree_.__setstate__(st) elif is_sklearn_tree(model): # sklearn trees and GreedyTree est = copy.deepcopy(model) else: inner = getattr(model, "model", None) if name.startswith("TaoTree") else getattr(model, "estimator_", None) if inner is None or not (is_sklearn_tree(inner) or hasattr(inner, "estimators_")): raise TypeError(f"{name} is not a fitted tree-based model that can be exported to scikit-learn.") est = copy.deepcopy(inner) # TaoTree, DecisionTreeCCP, HSTree (shrunk values live in estimator_) if not is_sklearn_tree(est): # hierarchical shrinkage of a forest if X is not None: for e in est.estimators_: recount(getattr(e, "estimator_", e), X) return est if X is not None: recount(est, np.asarray(X, dtype=float) if not hasattr(X, "columns") else X) return est