Coverage for mlprodict/onnx_conv/operator_converters/conv_lightgbm.py: 87%
Shortcuts on this page
r m x toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
Shortcuts on this page
r m x toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
1"""
2@file
3@brief Modified converter from
4`LightGbm.py <https://github.com/onnx/onnxmltools/blob/master/onnxmltools/convert/
5lightgbm/operator_converters/LightGbm.py>`_.
6"""
7from collections import Counter
8import copy
9import numbers
10import pprint
11import numpy
12from onnx import TensorProto
13from skl2onnx.common._apply_operation import apply_div, apply_reshape, apply_sub # pylint: disable=E0611
14from skl2onnx.common.tree_ensemble import get_default_tree_classifier_attribute_pairs
15from skl2onnx.proto import onnx_proto
16from skl2onnx.common.shape_calculator import (
17 calculate_linear_regressor_output_shapes,
18 calculate_linear_classifier_output_shapes)
19from skl2onnx.common.data_types import guess_numpy_type
20from skl2onnx.common.tree_ensemble import sklearn_threshold
21from ..sklconv.tree_converters import _fix_tree_ensemble
22from ..helpers.lgbm_helper import (
23 dump_lgbm_booster, modify_tree_for_rule_in_set)
26def calculate_lightgbm_output_shapes(operator):
27 """
28 Shape calculator for LightGBM Booster
29 (see :epkg:`lightgbm`).
30 """
31 op = operator.raw_operator
32 if hasattr(op, "_model_dict"):
33 objective = op._model_dict['objective']
34 elif hasattr(op, 'objective_'):
35 objective = op.objective_
36 else:
37 raise RuntimeError( # pragma: no cover
38 "Unable to find attributes '_model_dict' or 'objective_' in "
39 "instance of type %r (list of attributes=%r)." % (
40 type(op), dir(op)))
41 if objective.startswith('binary') or objective.startswith('multiclass'):
42 return calculate_linear_classifier_output_shapes(operator)
43 if objective.startswith('regression'): # pragma: no cover
44 return calculate_linear_regressor_output_shapes(operator)
45 raise NotImplementedError( # pragma: no cover
46 "Objective '{}' is not implemented yet.".format(objective))
49def _translate_split_criterion(criterion):
50 # If the criterion is true, LightGBM use the left child. Otherwise, right child is selected.
51 if criterion == '<=':
52 return 'BRANCH_LEQ'
53 if criterion == '<': # pragma: no cover
54 return 'BRANCH_LT'
55 if criterion == '>=': # pragma: no cover
56 return 'BRANCH_GTE'
57 if criterion == '>': # pragma: no cover
58 return 'BRANCH_GT'
59 if criterion == '==': # pragma: no cover
60 return 'BRANCH_EQ'
61 if criterion == '!=': # pragma: no cover
62 return 'BRANCH_NEQ'
63 raise ValueError( # pragma: no cover
64 'Unsupported splitting criterion: %s. Only <=, '
65 '<, >=, and > are allowed.')
68def _create_node_id(node_id_pool):
69 i = 0
70 while i in node_id_pool:
71 i += 1
72 node_id_pool.add(i)
73 return i
76def _parse_tree_structure(tree_id, class_id, learning_rate,
77 tree_structure, attrs):
78 """
79 The pool of all nodes' indexes created when parsing a single tree.
80 Different tree use different pools.
81 """
82 node_id_pool = set()
83 node_pyid_pool = dict()
85 node_id = _create_node_id(node_id_pool)
86 node_pyid_pool[id(tree_structure)] = node_id
88 # The root node is a leaf node.
89 if ('left_child' not in tree_structure or
90 'right_child' not in tree_structure):
91 _parse_node(tree_id, class_id, node_id, node_id_pool, node_pyid_pool,
92 learning_rate, tree_structure, attrs)
93 return
95 left_pyid = id(tree_structure['left_child'])
96 right_pyid = id(tree_structure['right_child'])
98 if left_pyid in node_pyid_pool:
99 left_id = node_pyid_pool[left_pyid]
100 left_parse = False
101 else:
102 left_id = _create_node_id(node_id_pool)
103 node_pyid_pool[left_pyid] = left_id
104 left_parse = True
106 if right_pyid in node_pyid_pool:
107 right_id = node_pyid_pool[right_pyid]
108 right_parse = False
109 else:
110 right_id = _create_node_id(node_id_pool)
111 node_pyid_pool[right_pyid] = right_id
112 right_parse = True
114 attrs['nodes_treeids'].append(tree_id)
115 attrs['nodes_nodeids'].append(node_id)
117 attrs['nodes_featureids'].append(tree_structure['split_feature'])
118 mode = _translate_split_criterion(tree_structure['decision_type'])
119 attrs['nodes_modes'].append(mode)
121 if isinstance(tree_structure['threshold'], str):
122 try: # pragma: no cover
123 th = float(tree_structure['threshold']) # pragma: no cover
124 except ValueError as e: # pragma: no cover
125 text = pprint.pformat(tree_structure)
126 if len(text) > 99999:
127 text = text[:99999] + "\n..."
128 raise TypeError("threshold must be a number not '{}'"
129 "\n{}".format(tree_structure['threshold'], text)) from e
130 else:
131 th = tree_structure['threshold']
132 if mode == 'BRANCH_LEQ':
133 th2 = sklearn_threshold(th, numpy.float32, mode)
134 else:
135 # other decision criteria are not implemented
136 th2 = th
137 attrs['nodes_values'].append(th2)
139 # Assume left is the true branch and right is the false branch
140 attrs['nodes_truenodeids'].append(left_id)
141 attrs['nodes_falsenodeids'].append(right_id)
142 if tree_structure['default_left']:
143 # attrs['nodes_missing_value_tracks_true'].append(1)
144 if (tree_structure["missing_type"] in ('None', None) and
145 float(tree_structure['threshold']) < 0.0):
146 attrs['nodes_missing_value_tracks_true'].append(0)
147 else:
148 attrs['nodes_missing_value_tracks_true'].append(1)
149 else:
150 attrs['nodes_missing_value_tracks_true'].append(0)
151 attrs['nodes_hitrates'].append(1.)
152 if left_parse:
153 _parse_node(
154 tree_id, class_id, left_id, node_id_pool, node_pyid_pool,
155 learning_rate, tree_structure['left_child'], attrs)
156 if right_parse:
157 _parse_node(
158 tree_id, class_id, right_id, node_id_pool, node_pyid_pool,
159 learning_rate, tree_structure['right_child'], attrs)
162def _parse_node(tree_id, class_id, node_id, node_id_pool, node_pyid_pool,
163 learning_rate, node, attrs):
164 """
165 Parses nodes.
166 """
167 if ((hasattr(node, 'left_child') and hasattr(node, 'right_child')) or
168 ('left_child' in node and 'right_child' in node)):
170 left_pyid = id(node['left_child'])
171 right_pyid = id(node['right_child'])
173 if left_pyid in node_pyid_pool:
174 left_id = node_pyid_pool[left_pyid]
175 left_parse = False
176 else:
177 left_id = _create_node_id(node_id_pool)
178 node_pyid_pool[left_pyid] = left_id
179 left_parse = True
181 if right_pyid in node_pyid_pool:
182 right_id = node_pyid_pool[right_pyid]
183 right_parse = False
184 else:
185 right_id = _create_node_id(node_id_pool)
186 node_pyid_pool[right_pyid] = right_id
187 right_parse = True
189 attrs['nodes_treeids'].append(tree_id)
190 attrs['nodes_nodeids'].append(node_id)
192 attrs['nodes_featureids'].append(node['split_feature'])
193 attrs['nodes_modes'].append(
194 _translate_split_criterion(node['decision_type']))
195 if isinstance(node['threshold'], str):
196 try: # pragma: no cover
197 attrs['nodes_values'].append( # pragma: no cover
198 float(node['threshold']))
199 except ValueError as e: # pragma: no cover
200 text = pprint.pformat(node)
201 if len(text) > 99999:
202 text = text[:99999] + "\n..."
203 raise TypeError("threshold must be a number not '{}'"
204 "\n{}".format(node['threshold'], text)) from e
205 else:
206 attrs['nodes_values'].append(node['threshold'])
208 # Assume left is the true branch and right is the false branch
209 attrs['nodes_truenodeids'].append(left_id)
210 attrs['nodes_falsenodeids'].append(right_id)
211 if node['default_left']:
212 # attrs['nodes_missing_value_tracks_true'].append(1)
213 if (node['missing_type'] in ('None', None) and
214 float(node['threshold']) < 0.0):
215 attrs['nodes_missing_value_tracks_true'].append(0)
216 else:
217 attrs['nodes_missing_value_tracks_true'].append(1)
218 else:
219 attrs['nodes_missing_value_tracks_true'].append(0)
220 attrs['nodes_hitrates'].append(1.)
222 # Recursively dive into the child nodes
223 if left_parse:
224 _parse_node(
225 tree_id, class_id, left_id, node_id_pool, node_pyid_pool,
226 learning_rate, node['left_child'], attrs)
227 if right_parse:
228 _parse_node(
229 tree_id, class_id, right_id, node_id_pool, node_pyid_pool,
230 learning_rate, node['right_child'], attrs)
231 elif hasattr(node, 'left_child') or hasattr(node, 'right_child'):
232 raise ValueError('Need two branches') # pragma: no cover
233 else:
234 # Node attributes
235 attrs['nodes_treeids'].append(tree_id)
236 attrs['nodes_nodeids'].append(node_id)
237 attrs['nodes_featureids'].append(0)
238 attrs['nodes_modes'].append('LEAF')
239 # Leaf node has no threshold. A zero is appended but it will never be used.
240 attrs['nodes_values'].append(0.)
241 # Leaf node has no child. A zero is appended but it will never be used.
242 attrs['nodes_truenodeids'].append(0)
243 # Leaf node has no child. A zero is appended but it will never be used.
244 attrs['nodes_falsenodeids'].append(0)
245 # Leaf node has no split function. A zero is appended but it will never be used.
246 attrs['nodes_missing_value_tracks_true'].append(0)
247 attrs['nodes_hitrates'].append(1.)
249 # Leaf attributes
250 attrs['class_treeids'].append(tree_id)
251 attrs['class_nodeids'].append(node_id)
252 attrs['class_ids'].append(class_id)
253 attrs['class_weights'].append(
254 float(node['leaf_value']) * learning_rate)
257def _split_tree_ensemble_atts(attrs, split):
258 """
259 Splits the attributes of a TreeEnsembleRegressor into
260 multiple trees in order to do the summation in double instead of floats.
261 """
262 trees_id = list(sorted(set(attrs['nodes_treeids'])))
263 results = []
264 index = 0
265 while index < len(trees_id):
266 index2 = min(index + split, len(trees_id))
267 subset = set(trees_id[index: index2])
269 indices_node = []
270 indices_target = []
271 for j, v in enumerate(attrs['nodes_treeids']):
272 if v in subset:
273 indices_node.append(j)
274 for j, v in enumerate(attrs['target_treeids']):
275 if v in subset:
276 indices_target.append(j)
278 if (len(indices_node) >= len(attrs['nodes_treeids']) or
279 len(indices_target) >= len(attrs['target_treeids'])):
280 raise RuntimeError( # pragma: no cover
281 "Initial attributes are not consistant."
282 "\nindex=%r index2=%r subset=%r"
283 "\nnodes_treeids=%r\ntarget_treeids=%r"
284 "\nindices_node=%r\nindices_target=%r" % (
285 index, index2, subset,
286 attrs['nodes_treeids'], attrs['target_treeids'],
287 indices_node, indices_target))
289 ats = {}
290 for name, att in attrs.items():
291 if name == 'nodes_treeids':
292 new_att = [att[i] for i in indices_node]
293 new_att = [i - att[0] for i in new_att]
294 elif name == 'target_treeids':
295 new_att = [att[i] for i in indices_target]
296 new_att = [i - att[0] for i in new_att]
297 elif name.startswith("nodes_"):
298 new_att = [att[i] for i in indices_node]
299 assert len(new_att) == len(indices_node)
300 elif name.startswith("target_"):
301 new_att = [att[i] for i in indices_target]
302 assert len(new_att) == len(indices_target)
303 elif name == 'name':
304 new_att = "%s%d" % (att, len(results))
305 else:
306 new_att = att
307 ats[name] = new_att
309 results.append(ats)
310 index = index2
312 return results
315def convert_lightgbm(scope, operator, container): # pylint: disable=R0914
316 """
317 This converters reuses the code from
318 `LightGbm.py <https://github.com/onnx/onnxmltools/blob/master/onnxmltools/convert/
319 lightgbm/operator_converters/LightGbm.py>`_ and makes
320 some modifications. It implements converters
321 for models in :epkg:`lightgbm`.
322 """
323 verbose = getattr(container, 'verbose', 0)
324 gbm_model = operator.raw_operator
325 if hasattr(gbm_model, '_model_dict_info'):
326 gbm_text, info = gbm_model._model_dict_info
327 else:
328 if verbose >= 2:
329 print("[convert_lightgbm] dump_model") # pragma: no cover
330 gbm_text, info = dump_lgbm_booster(gbm_model.booster_, verbose=verbose)
331 opsetml = container.target_opset_all.get('ai.onnx.ml', None)
332 if opsetml is None:
333 opsetml = 3 if container.target_opset >= 16 else 1
334 if verbose >= 2:
335 print( # pragma: no cover
336 "[convert_lightgbm] modify_tree_for_rule_in_set")
337 modify_tree_for_rule_in_set(gbm_text, use_float=True, verbose=verbose,
338 info=info)
340 attrs = get_default_tree_classifier_attribute_pairs()
341 attrs['name'] = operator.full_name
343 # Create different attributes for classifier and
344 # regressor, respectively
345 post_transform = None
346 if gbm_text['objective'].startswith('binary'):
347 n_classes = 1
348 attrs['post_transform'] = 'LOGISTIC'
349 elif gbm_text['objective'].startswith('multiclass'):
350 n_classes = gbm_text['num_class']
351 attrs['post_transform'] = 'SOFTMAX'
352 elif gbm_text['objective'].startswith('regression'):
353 n_classes = 1 # Regressor has only one output variable
354 attrs['post_transform'] = 'NONE'
355 attrs['n_targets'] = n_classes
356 elif gbm_text['objective'].startswith(('poisson', 'gamma')):
357 n_classes = 1 # Regressor has only one output variable
358 attrs['n_targets'] = n_classes
359 # 'Exp' is not a supported post_transform value in the ONNX spec yet,
360 # so we need to add an 'Exp' post transform node to the model
361 attrs['post_transform'] = 'NONE'
362 post_transform = "Exp"
363 else:
364 raise RuntimeError( # pragma: no cover
365 "LightGBM objective should be cleaned already not '{}'.".format(
366 gbm_text['objective']))
368 # Use the same algorithm to parse the tree
369 if verbose >= 2: # pragma: no cover
370 from tqdm import tqdm
371 loop = tqdm(gbm_text['tree_info'])
372 loop.set_description("parse")
373 else:
374 loop = gbm_text['tree_info']
375 for i, tree in enumerate(loop):
376 tree_id = i
377 class_id = tree_id % n_classes
378 # tree['shrinkage'] --> LightGbm provides figures with it already.
379 learning_rate = 1.
380 _parse_tree_structure(
381 tree_id, class_id, learning_rate, tree['tree_structure'], attrs)
383 if verbose >= 2:
384 print("[convert_lightgbm] onnx") # pragma: no cover
385 # Sort nodes_* attributes. For one tree, its node indexes
386 # should appear in an ascent order in nodes_nodeids. Nodes
387 # from a tree with a smaller tree index should appear
388 # before trees with larger indexes in nodes_nodeids.
389 node_numbers_per_tree = Counter(attrs['nodes_treeids'])
390 tree_number = len(node_numbers_per_tree.keys())
391 accumulated_node_numbers = [0] * tree_number
392 for i in range(1, tree_number):
393 accumulated_node_numbers[i] = (
394 accumulated_node_numbers[i - 1] + node_numbers_per_tree[i - 1])
395 global_node_indexes = []
396 for i in range(len(attrs['nodes_nodeids'])):
397 tree_id = attrs['nodes_treeids'][i]
398 node_id = attrs['nodes_nodeids'][i]
399 global_node_indexes.append(
400 accumulated_node_numbers[tree_id] + node_id)
401 for k, v in attrs.items():
402 if k.startswith('nodes_'):
403 merged_indexes = zip(
404 copy.deepcopy(global_node_indexes), v)
405 sorted_list = [pair[1]
406 for pair in sorted(merged_indexes,
407 key=lambda x: x[0])]
408 attrs[k] = sorted_list
410 dtype = guess_numpy_type(operator.inputs[0].type)
411 if dtype != numpy.float64:
412 dtype = numpy.float32
414 if dtype == numpy.float64:
415 for key in ['nodes_values', 'nodes_hitrates', 'target_weights',
416 'class_weights', 'base_values']:
417 if key not in attrs:
418 continue
419 attrs[key] = numpy.array(attrs[key], dtype=dtype)
421 # Create ONNX object
422 if (gbm_text['objective'].startswith('binary') or
423 gbm_text['objective'].startswith('multiclass')):
424 # Prepare label information for both of TreeEnsembleClassifier
425 # and ZipMap
426 class_type = onnx_proto.TensorProto.STRING # pylint: disable=E1101
427 zipmap_attrs = {'name': scope.get_unique_variable_name('ZipMap')}
428 if all(isinstance(i, (numbers.Real, bool, numpy.bool_))
429 for i in gbm_model.classes_):
430 class_type = onnx_proto.TensorProto.INT64 # pylint: disable=E1101
431 class_labels = [int(i) for i in gbm_model.classes_]
432 attrs['classlabels_int64s'] = class_labels
433 zipmap_attrs['classlabels_int64s'] = class_labels
434 elif all(isinstance(i, str) for i in gbm_model.classes_):
435 class_labels = [str(i) for i in gbm_model.classes_]
436 attrs['classlabels_strings'] = class_labels
437 zipmap_attrs['classlabels_strings'] = class_labels
438 else:
439 raise ValueError( # pragma: no cover
440 'Only string and integer class labels are allowed')
442 # Create tree classifier
443 probability_tensor_name = scope.get_unique_variable_name(
444 'probability_tensor')
445 label_tensor_name = scope.get_unique_variable_name('label_tensor')
447 if dtype == numpy.float64 and opsetml < 3:
448 container.add_node('TreeEnsembleClassifierDouble', operator.input_full_names,
449 [label_tensor_name, probability_tensor_name],
450 op_domain='mlprodict', op_version=1, **attrs)
451 else:
452 container.add_node('TreeEnsembleClassifier', operator.input_full_names,
453 [label_tensor_name, probability_tensor_name],
454 op_domain='ai.onnx.ml', op_version=1, **attrs)
456 prob_tensor = probability_tensor_name
458 if gbm_model.boosting_type == 'rf':
459 col_index_name = scope.get_unique_variable_name('col_index')
460 first_col_name = scope.get_unique_variable_name('first_col')
461 zeroth_col_name = scope.get_unique_variable_name('zeroth_col')
462 denominator_name = scope.get_unique_variable_name('denominator')
463 modified_first_col_name = scope.get_unique_variable_name(
464 'modified_first_col')
465 unit_float_tensor_name = scope.get_unique_variable_name(
466 'unit_float_tensor')
467 merged_prob_name = scope.get_unique_variable_name('merged_prob')
468 predicted_label_name = scope.get_unique_variable_name(
469 'predicted_label')
470 classes_name = scope.get_unique_variable_name('classes')
471 final_label_name = scope.get_unique_variable_name('final_label')
473 container.add_initializer(
474 col_index_name, onnx_proto.TensorProto.INT64, [], [1]) # pylint: disable=E1101
475 container.add_initializer(
476 unit_float_tensor_name, onnx_proto.TensorProto.FLOAT, [], [1.0]) # pylint: disable=E1101
477 container.add_initializer(
478 denominator_name, onnx_proto.TensorProto.FLOAT, [], [100.0]) # pylint: disable=E1101
479 container.add_initializer(classes_name, class_type,
480 [len(class_labels)], class_labels)
482 container.add_node(
483 'ArrayFeatureExtractor',
484 [probability_tensor_name, col_index_name],
485 first_col_name,
486 name=scope.get_unique_operator_name(
487 'ArrayFeatureExtractor'),
488 op_domain='ai.onnx.ml')
489 apply_div(scope, [first_col_name, denominator_name],
490 modified_first_col_name, container, broadcast=1)
491 apply_sub(
492 scope, [unit_float_tensor_name, modified_first_col_name],
493 zeroth_col_name, container, broadcast=1)
494 container.add_node(
495 'Concat', [zeroth_col_name, modified_first_col_name],
496 merged_prob_name,
497 name=scope.get_unique_operator_name('Concat'), axis=1)
498 container.add_node(
499 'ArgMax', merged_prob_name,
500 predicted_label_name,
501 name=scope.get_unique_operator_name('ArgMax'), axis=1)
502 container.add_node(
503 'ArrayFeatureExtractor', [classes_name, predicted_label_name],
504 final_label_name,
505 name=scope.get_unique_operator_name('ArrayFeatureExtractor'),
506 op_domain='ai.onnx.ml')
507 apply_reshape(scope, final_label_name,
508 operator.outputs[0].full_name,
509 container, desired_shape=[-1, ])
510 prob_tensor = merged_prob_name
511 else:
512 container.add_node('Identity', label_tensor_name,
513 operator.outputs[0].full_name,
514 name=scope.get_unique_operator_name('Identity'))
516 # Convert probability tensor to probability map
517 # (keys are labels while values are the associated probabilities)
518 container.add_node('Identity', prob_tensor,
519 operator.outputs[1].full_name)
520 else:
521 # Create tree regressor
522 output_name = scope.get_unique_variable_name('output')
524 keys_to_be_renamed = list(
525 k for k in attrs if k.startswith('class_'))
527 for k in keys_to_be_renamed:
528 # Rename class_* attribute to target_*
529 # because TreeEnsebmleClassifier
530 # and TreeEnsembleClassifier have different ONNX attributes
531 attrs['target' + k[5:]] = copy.deepcopy(attrs[k])
532 del attrs[k]
534 options = container.get_options(gbm_model, dict(split=-1))
535 split = options['split']
536 if split == -1:
537 if dtype == numpy.float64 and opsetml < 3:
538 container.add_node(
539 'TreeEnsembleRegressorDouble', operator.input_full_names,
540 output_name, op_domain='mlprodict', op_version=1, **attrs)
541 else:
542 container.add_node(
543 'TreeEnsembleRegressor', operator.input_full_names,
544 output_name, op_domain='ai.onnx.ml', op_version=1, **attrs)
545 else:
546 tree_attrs = _split_tree_ensemble_atts(attrs, split)
547 tree_nodes = []
548 for i, ats in enumerate(tree_attrs):
549 tree_name = scope.get_unique_variable_name('tree%d' % i)
550 if dtype == numpy.float64:
551 container.add_node(
552 'TreeEnsembleRegressorDouble', operator.input_full_names,
553 tree_name, op_domain='mlprodict', op_version=1, **ats)
554 tree_nodes.append(tree_name)
555 else:
556 container.add_node(
557 'TreeEnsembleRegressor', operator.input_full_names,
558 tree_name, op_domain='ai.onnx.ml', op_version=1, **ats)
559 cast_name = scope.get_unique_variable_name('dtree%d' % i)
560 container.add_node(
561 'Cast', tree_name, cast_name, to=TensorProto.DOUBLE, # pylint: disable=E1101
562 name=scope.get_unique_operator_name("dtree%d" % i))
563 tree_nodes.append(cast_name)
564 if dtype == numpy.float64:
565 container.add_node(
566 'Sum', tree_nodes, output_name,
567 name=scope.get_unique_operator_name("sumtree%d" % len(tree_nodes)))
568 else:
569 cast_name = scope.get_unique_variable_name('ftrees')
570 container.add_node(
571 'Sum', tree_nodes, cast_name,
572 name=scope.get_unique_operator_name("sumtree%d" % len(tree_nodes)))
573 container.add_node(
574 'Cast', cast_name, output_name, to=TensorProto.FLOAT, # pylint: disable=E1101
575 name=scope.get_unique_operator_name("dtree%d" % i))
577 if gbm_model.boosting_type == 'rf':
578 denominator_name = scope.get_unique_variable_name('denominator')
580 container.add_initializer(
581 denominator_name, onnx_proto.TensorProto.FLOAT, # pylint: disable=E1101
582 [], [100.0])
584 apply_div(scope, [output_name, denominator_name],
585 operator.output_full_names, container, broadcast=1)
586 elif post_transform:
587 container.add_node(
588 post_transform, output_name,
589 operator.output_full_names,
590 name=scope.get_unique_operator_name(
591 post_transform))
592 else:
593 container.add_node('Identity', output_name,
594 operator.output_full_names,
595 name=scope.get_unique_operator_name('Identity'))
596 if opsetml >= 3:
597 _fix_tree_ensemble(scope, container, opsetml, dtype)
598 if verbose >= 2:
599 print("[convert_lightgbm] end") # pragma: no cover