Skip to content

Commit 1e5884d

Browse files
committed
Adding export
1 parent a1c31ce commit 1e5884d

3 files changed

Lines changed: 312 additions & 20 deletions

File tree

sklearn/tree/_export.py

Lines changed: 203 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,109 @@ def _color_brew(n):
7575
return color_list
7676

7777

78+
def _validate_category_names(category_names, tree):
79+
"""Validate and normalize category labels for categorical split exports."""
80+
if category_names is None:
81+
return None
82+
83+
validated_category_names = {}
84+
for feature_idx, names in category_names.items():
85+
if not isinstance(feature_idx, Integral) or isinstance(feature_idx, bool):
86+
raise ValueError(
87+
"category_names keys must be integer feature indices. "
88+
f"Got key {feature_idx!r}."
89+
)
90+
91+
feature_idx = int(feature_idx)
92+
if feature_idx < 0 or feature_idx >= tree.n_features:
93+
raise ValueError(
94+
"category_names contains feature index "
95+
f"{feature_idx}, but the tree has {tree.n_features} features."
96+
)
97+
98+
n_categories = int(tree.n_categories[feature_idx])
99+
if n_categories <= 0:
100+
raise ValueError(
101+
"category_names contains feature index "
102+
f"{feature_idx}, which does not correspond to a categorical feature."
103+
)
104+
105+
names = np.asarray(names, dtype=object)
106+
if names.ndim != 1:
107+
raise ValueError(
108+
"category_names values must be one-dimensional arrays of labels."
109+
)
110+
111+
if names.shape[0] != n_categories:
112+
raise ValueError(
113+
"category_names for feature "
114+
f"{feature_idx} must contain {n_categories} labels, "
115+
f"got {names.shape[0]}."
116+
)
117+
118+
validated_category_names[feature_idx] = names
119+
120+
return validated_category_names
121+
122+
123+
def _categorical_bitset_to_codes(bitset, n_categories):
124+
"""Return category codes enabled in a categorical split bitset."""
125+
bitset = np.asarray(bitset).reshape(-1)
126+
bits_per_word = bitset.dtype.itemsize * 8
127+
return [
128+
code
129+
for code in range(n_categories)
130+
if int(bitset[code // bits_per_word]) & (1 << (code % bits_per_word))
131+
]
132+
133+
134+
def _format_category_value(code, category_names, feature_idx, str_escape=None):
135+
if category_names is None or feature_idx not in category_names:
136+
return str(code)
137+
138+
label = str(category_names[feature_idx][code])
139+
if str_escape is not None:
140+
label = str_escape(label)
141+
return f"{label} ({code})"
142+
143+
144+
def _format_category_set(codes, category_names, feature_idx, str_escape=None):
145+
categories = [
146+
_format_category_value(code, category_names, feature_idx, str_escape)
147+
for code in codes
148+
]
149+
return "{" + ", ".join(categories) + "}"
150+
151+
152+
def _format_categorical_bitset_predicates(
153+
tree, node_id, feature, category_names, str_escape=None
154+
):
155+
feature_idx = int(tree.feature[node_id])
156+
n_categories = int(tree.n_categories[feature_idx])
157+
left_codes = _categorical_bitset_to_codes(
158+
tree.categorical_bitset[node_id], n_categories
159+
)
160+
left_code_set = set(left_codes)
161+
right_codes = [
162+
code for code in range(n_categories) if code not in left_code_set
163+
]
164+
165+
if len(left_codes) <= len(right_codes):
166+
categories = _format_category_set(
167+
left_codes, category_names, feature_idx, str_escape
168+
)
169+
return f"{feature} in {categories}", f"{feature} not in {categories}"
170+
171+
categories = _format_category_set(
172+
right_codes, category_names, feature_idx, str_escape
173+
)
174+
return f"{feature} not in {categories}", f"{feature} in {categories}"
175+
176+
177+
def _format_categorical_hash_split(feature):
178+
return f"{feature} categorical hash split"
179+
180+
78181
class Sentinel:
79182
def __repr__(self):
80183
return '"tree.dot"'
@@ -88,6 +191,7 @@ def __repr__(self):
88191
"decision_tree": [DecisionTreeClassifier, DecisionTreeRegressor],
89192
"max_depth": [Interval(Integral, 0, None, closed="left"), None],
90193
"feature_names": ["array-like", None],
194+
"category_names": [dict, None],
91195
"class_names": ["array-like", "boolean", None],
92196
"label": [StrOptions({"all", "root", "none"})],
93197
"filled": ["boolean"],
@@ -106,6 +210,7 @@ def plot_tree(
106210
*,
107211
max_depth=None,
108212
feature_names=None,
213+
category_names=None,
109214
class_names=None,
110215
label="all",
111216
filled=False,
@@ -143,6 +248,13 @@ def plot_tree(
143248
Names of each of the features.
144249
If None, generic names will be used ("x[0]", "x[1]", ...).
145250
251+
category_names : dict of int to array-like, default=None
252+
Names of each category for categorical features. The keys are integer
253+
feature indices and the values are arrays of labels ordered by category
254+
code.
255+
256+
.. versionadded:: 1.9
257+
146258
class_names : array-like of str or True, default=None
147259
Names of each of the target classes in ascending numerical order.
148260
Only relevant for classification and not supported for multi-output.
@@ -203,10 +315,12 @@ def plot_tree(
203315
"""
204316

205317
check_is_fitted(decision_tree)
318+
category_names = _validate_category_names(category_names, decision_tree.tree_)
206319

207320
exporter = _MPLTreeExporter(
208321
max_depth=max_depth,
209322
feature_names=feature_names,
323+
category_names=category_names,
210324
class_names=class_names,
211325
label=label,
212326
filled=filled,
@@ -225,6 +339,7 @@ def __init__(
225339
self,
226340
max_depth=None,
227341
feature_names=None,
342+
category_names=None,
228343
class_names=None,
229344
label="all",
230345
filled=False,
@@ -237,6 +352,7 @@ def __init__(
237352
):
238353
self.max_depth = max_depth
239354
self.feature_names = feature_names
355+
self.category_names = category_names
240356
self.class_names = class_names
241357
self.label = label
242358
self.filled = filled
@@ -330,12 +446,26 @@ def node_to_str(self, tree, node_id, criterion):
330446
tree.feature[node_id],
331447
characters[2],
332448
)
333-
node_string += "%s %s %s%s" % (
334-
feature,
335-
characters[3],
336-
round(tree.threshold[node_id], self.precision),
337-
characters[4],
338-
)
449+
split_kind = int(tree.split_kind[node_id])
450+
if split_kind == _tree.SPLIT_CATEGORICAL_BITSET:
451+
node_string += _format_categorical_bitset_predicates(
452+
tree,
453+
node_id,
454+
feature,
455+
self.category_names,
456+
self.str_escape,
457+
)[0]
458+
node_string += characters[4]
459+
elif split_kind == _tree.SPLIT_CATEGORICAL_HASH:
460+
node_string += _format_categorical_hash_split(feature)
461+
node_string += characters[4]
462+
else:
463+
node_string += "%s %s %s%s" % (
464+
feature,
465+
characters[3],
466+
round(tree.threshold[node_id], self.precision),
467+
characters[4],
468+
)
339469

340470
# Write impurity
341471
if self.impurity:
@@ -422,6 +552,7 @@ def __init__(
422552
out_file=SENTINEL,
423553
max_depth=None,
424554
feature_names=None,
555+
category_names=None,
425556
class_names=None,
426557
label="all",
427558
filled=False,
@@ -438,6 +569,7 @@ def __init__(
438569
super().__init__(
439570
max_depth=max_depth,
440571
feature_names=feature_names,
572+
category_names=category_names,
441573
class_names=class_names,
442574
label=label,
443575
filled=filled,
@@ -599,6 +731,7 @@ def __init__(
599731
self,
600732
max_depth=None,
601733
feature_names=None,
734+
category_names=None,
602735
class_names=None,
603736
label="all",
604737
filled=False,
@@ -612,6 +745,7 @@ def __init__(
612745
super().__init__(
613746
max_depth=max_depth,
614747
feature_names=feature_names,
748+
category_names=category_names,
615749
class_names=class_names,
616750
label=label,
617751
filled=filled,
@@ -777,6 +911,7 @@ def recurse(self, node, tree, ax, max_x, max_y, depth=0):
777911
"out_file": [str, None, HasMethods("write")],
778912
"max_depth": [Interval(Integral, 0, None, closed="left"), None],
779913
"feature_names": ["array-like", None],
914+
"category_names": [dict, None],
780915
"class_names": ["array-like", "boolean", None],
781916
"label": [StrOptions({"all", "root", "none"})],
782917
"filled": ["boolean"],
@@ -798,6 +933,7 @@ def export_graphviz(
798933
*,
799934
max_depth=None,
800935
feature_names=None,
936+
category_names=None,
801937
class_names=None,
802938
label="all",
803939
filled=False,
@@ -845,6 +981,13 @@ def export_graphviz(
845981
An array containing the feature names.
846982
If None, generic names will be used ("x[0]", "x[1]", ...).
847983
984+
category_names : dict of int to array-like, default=None
985+
Names of each category for categorical features. The keys are integer
986+
feature indices and the values are arrays of labels ordered by category
987+
code.
988+
989+
.. versionadded:: 1.9
990+
848991
class_names : array-like of shape (n_classes,) or bool, default=None
849992
Names of each of the target classes in ascending numerical order.
850993
Only relevant for classification and not supported for multi-output.
@@ -922,6 +1065,7 @@ def export_graphviz(
9221065
)
9231066

9241067
check_is_fitted(decision_tree)
1068+
category_names = _validate_category_names(category_names, decision_tree.tree_)
9251069
own_file = False
9261070
return_string = False
9271071
try:
@@ -937,6 +1081,7 @@ def export_graphviz(
9371081
out_file=out_file,
9381082
max_depth=max_depth,
9391083
feature_names=feature_names,
1084+
category_names=category_names,
9401085
class_names=class_names,
9411086
label=label,
9421087
filled=filled,
@@ -988,6 +1133,7 @@ def compute_depth_(
9881133
{
9891134
"decision_tree": [DecisionTreeClassifier, DecisionTreeRegressor],
9901135
"feature_names": ["array-like", None],
1136+
"category_names": [dict, None],
9911137
"class_names": ["array-like", None],
9921138
"max_depth": [Interval(Integral, 0, None, closed="left"), None],
9931139
"spacing": [Interval(Integral, 1, None, closed="left"), None],
@@ -1000,6 +1146,7 @@ def export_text(
10001146
decision_tree,
10011147
*,
10021148
feature_names=None,
1149+
category_names=None,
10031150
class_names=None,
10041151
max_depth=10,
10051152
spacing=3,
@@ -1021,6 +1168,13 @@ def export_text(
10211168
An array containing the feature names.
10221169
If None generic names will be used ("feature_0", "feature_1", ...).
10231170
1171+
category_names : dict of int to array-like, default=None
1172+
Names of each category for categorical features. The keys are integer
1173+
feature indices and the values are arrays of labels ordered by category
1174+
code.
1175+
1176+
.. versionadded:: 1.9
1177+
10241178
class_names : array-like of shape (n_classes,), default=None
10251179
Names of each of the target classes in ascending numerical order.
10261180
Only relevant for classification and not supported for multi-output.
@@ -1083,6 +1237,7 @@ def export_text(
10831237

10841238
check_is_fitted(decision_tree)
10851239
tree_ = decision_tree.tree_
1240+
category_names = _validate_category_names(category_names, tree_)
10861241
if is_classifier(decision_tree):
10871242
if class_names is None:
10881243
class_names = decision_tree.classes_
@@ -1095,6 +1250,7 @@ def export_text(
10951250
)
10961251
right_child_fmt = "{} {} <= {}\n"
10971252
left_child_fmt = "{} {} > {}\n"
1253+
categorical_child_fmt = "{} {}\n"
10981254
truncation_fmt = "{} {}\n"
10991255

11001256
if feature_names is not None and len(feature_names) != tree_.n_features:
@@ -1159,15 +1315,47 @@ def print_tree_recurse(report, node, depth):
11591315

11601316
if tree_.feature[node] != _tree.TREE_UNDEFINED:
11611317
name = feature_names_[node]
1162-
threshold = tree_.threshold[node]
1163-
threshold = "{1:.{0}f}".format(decimals, threshold)
1164-
report.write(right_child_fmt.format(indent, name, threshold))
1165-
report.write(info_fmt_left)
1166-
print_tree_recurse(report, tree_.children_left[node], depth + 1)
1167-
1168-
report.write(left_child_fmt.format(indent, name, threshold))
1169-
report.write(info_fmt_right)
1170-
print_tree_recurse(report, tree_.children_right[node], depth + 1)
1318+
split_kind = int(tree_.split_kind[node])
1319+
if split_kind == _tree.SPLIT_CATEGORICAL_BITSET:
1320+
left_predicate, right_predicate = (
1321+
_format_categorical_bitset_predicates(
1322+
tree_, node, name, category_names
1323+
)
1324+
)
1325+
report.write(categorical_child_fmt.format(indent, left_predicate))
1326+
report.write(info_fmt_left)
1327+
print_tree_recurse(report, tree_.children_left[node], depth + 1)
1328+
1329+
report.write(categorical_child_fmt.format(indent, right_predicate))
1330+
report.write(info_fmt_right)
1331+
print_tree_recurse(report, tree_.children_right[node], depth + 1)
1332+
elif split_kind == _tree.SPLIT_CATEGORICAL_HASH:
1333+
split_description = _format_categorical_hash_split(name)
1334+
report.write(
1335+
categorical_child_fmt.format(
1336+
indent, f"{split_description}: left"
1337+
)
1338+
)
1339+
report.write(info_fmt_left)
1340+
print_tree_recurse(report, tree_.children_left[node], depth + 1)
1341+
1342+
report.write(
1343+
categorical_child_fmt.format(
1344+
indent, f"{split_description}: right"
1345+
)
1346+
)
1347+
report.write(info_fmt_right)
1348+
print_tree_recurse(report, tree_.children_right[node], depth + 1)
1349+
else:
1350+
threshold = tree_.threshold[node]
1351+
threshold = "{1:.{0}f}".format(decimals, threshold)
1352+
report.write(right_child_fmt.format(indent, name, threshold))
1353+
report.write(info_fmt_left)
1354+
print_tree_recurse(report, tree_.children_left[node], depth + 1)
1355+
1356+
report.write(left_child_fmt.format(indent, name, threshold))
1357+
report.write(info_fmt_right)
1358+
print_tree_recurse(report, tree_.children_right[node], depth + 1)
11711359
else: # leaf
11721360
_add_leaf(value, weighted_n_node_samples, class_name, indent)
11731361
else:

0 commit comments

Comments
 (0)