@@ -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+
78181class 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