plotting.py 31.7 KB
Newer Older
1
# coding: utf-8
2
"""Plotting library."""
3
import math
4
from copy import deepcopy
wxchan's avatar
wxchan committed
5
from io import BytesIO
6
from typing import Any, Dict, List, Optional, Tuple, Union
wxchan's avatar
wxchan committed
7

8
9
import numpy as np

10
from .basic import Booster, _data_from_pandas, _is_zero, _log_warning, _MissingType
11
from .compat import GRAPHVIZ_INSTALLED, MATPLOTLIB_INSTALLED, pd_DataFrame
12
13
from .sklearn import LGBMModel

14
15
16
17
18
19
20
21
__all__ = [
    'create_tree_digraph',
    'plot_importance',
    'plot_metric',
    'plot_split_value_histogram',
    'plot_tree',
]

22

23
def _check_not_tuple_of_2_elements(obj: Any, obj_name: str) -> None:
24
    """Check object is not tuple or does not have 2 elements."""
25
    if not isinstance(obj, tuple) or len(obj) != 2:
26
        raise TypeError(f"{obj_name} must be a tuple of 2 elements.")
wxchan's avatar
wxchan committed
27
28


29
def _float2str(value: float, precision: Optional[int]) -> str:
30
    return (f"{value:.{precision}f}"
31
            if precision is not None and not isinstance(value, str)
32
33
34
            else str(value))


35
36
37
38
39
40
41
42
43
def plot_importance(
    booster: Union[Booster, LGBMModel],
    ax=None,
    height: float = 0.2,
    xlim: Optional[Tuple[float, float]] = None,
    ylim: Optional[Tuple[float, float]] = None,
    title: Optional[str] = 'Feature importance',
    xlabel: Optional[str] = 'Feature importance',
    ylabel: Optional[str] = 'Features',
44
    importance_type: str = 'auto',
45
46
47
48
49
50
51
52
    max_num_features: Optional[int] = None,
    ignore_zero: bool = True,
    figsize: Optional[Tuple[float, float]] = None,
    dpi: Optional[int] = None,
    grid: bool = True,
    precision: Optional[int] = 3,
    **kwargs: Any
) -> Any:
53
    """Plot model's feature importances.
54
55
56

    Parameters
    ----------
wxchan's avatar
wxchan committed
57
    booster : Booster or LGBMModel
58
59
60
61
62
63
64
65
66
67
        Booster or LGBMModel instance which feature importance should be plotted.
    ax : matplotlib.axes.Axes or None, optional (default=None)
        Target axes instance.
        If None, new figure and axes will be created.
    height : float, optional (default=0.2)
        Bar height, passed to ``ax.barh()``.
    xlim : tuple of 2 elements or None, optional (default=None)
        Tuple passed to ``ax.xlim()``.
    ylim : tuple of 2 elements or None, optional (default=None)
        Tuple passed to ``ax.ylim()``.
68
    title : str or None, optional (default="Feature importance")
69
70
        Axes title.
        If None, title is disabled.
71
    xlabel : str or None, optional (default="Feature importance")
72
73
        X-axis title label.
        If None, title is disabled.
74
        @importance_type@ placeholder can be used, and it will be replaced with the value of ``importance_type`` parameter.
75
    ylabel : str or None, optional (default="Features")
76
77
        Y-axis title label.
        If None, title is disabled.
78
    importance_type : str, optional (default="auto")
79
        How the importance is calculated.
80
        If "auto", if ``booster`` parameter is LGBMModel, ``booster.importance_type`` attribute is used; "split" otherwise.
81
82
83
        If "split", result contains numbers of times the feature is used in a model.
        If "gain", result contains total gains of splits which use the feature.
    max_num_features : int or None, optional (default=None)
84
        Max number of top features displayed on plot.
85
86
87
88
89
        If None or <1, all features will be displayed.
    ignore_zero : bool, optional (default=True)
        Whether to ignore features with zero importance.
    figsize : tuple of 2 elements or None, optional (default=None)
        Figure size.
90
91
    dpi : int or None, optional (default=None)
        Resolution of the figure.
92
93
    grid : bool, optional (default=True)
        Whether to add a grid for axes.
94
    precision : int or None, optional (default=3)
95
        Used to restrict the display of floating point values to a certain precision.
96
    **kwargs
97
        Other parameters passed to ``ax.barh()``.
98
99
100

    Returns
    -------
101
102
    ax : matplotlib.axes.Axes
        The plot with model's feature importances.
103
    """
104
    if MATPLOTLIB_INSTALLED:
105
        import matplotlib.pyplot as plt
106
    else:
107
        raise ImportError('You must install matplotlib and restart your session to plot importance.')
108
109

    if isinstance(booster, LGBMModel):
110
111
        if importance_type == "auto":
            importance_type = booster.importance_type
wxchan's avatar
wxchan committed
112
        booster = booster.booster_
113
114
115
116
    elif isinstance(booster, Booster):
        if importance_type == "auto":
            importance_type = "split"
    else:
wxchan's avatar
wxchan committed
117
118
119
120
        raise TypeError('booster must be Booster or LGBMModel.')

    importance = booster.feature_importance(importance_type=importance_type)
    feature_name = booster.feature_name()
121
122

    if not len(importance):
123
        raise ValueError("Booster's feature_importance is empty.")
124

125
    tuples = sorted(zip(feature_name, importance), key=lambda x: x[1])
126
127
128
129
    if ignore_zero:
        tuples = [x for x in tuples if x[1] > 0]
    if max_num_features is not None and max_num_features > 0:
        tuples = tuples[-max_num_features:]
130
    labels, values = zip(*tuples)
131
132

    if ax is None:
133
        if figsize is not None:
134
            _check_not_tuple_of_2_elements(figsize, 'figsize')
135
        _, ax = plt.subplots(1, 1, figsize=figsize, dpi=dpi)
136
137
138
139

    ylocs = np.arange(len(values))
    ax.barh(ylocs, values, align='center', height=height, **kwargs)

140
    for x, y in zip(values, ylocs):
141
142
143
        ax.text(x + 1, y,
                _float2str(x, precision) if importance_type == 'gain' else x,
                va='center')
144
145
146
147
148

    ax.set_yticks(ylocs)
    ax.set_yticklabels(labels)

    if xlim is not None:
149
        _check_not_tuple_of_2_elements(xlim, 'xlim')
150
151
152
153
154
    else:
        xlim = (0, max(values) * 1.1)
    ax.set_xlim(xlim)

    if ylim is not None:
155
        _check_not_tuple_of_2_elements(ylim, 'ylim')
156
157
158
159
160
161
162
    else:
        ylim = (-1, len(values))
    ax.set_ylim(ylim)

    if title is not None:
        ax.set_title(title)
    if xlabel is not None:
163
        xlabel = xlabel.replace('@importance_type@', importance_type)
164
165
166
167
168
        ax.set_xlabel(xlabel)
    if ylabel is not None:
        ax.set_ylabel(ylabel)
    ax.grid(grid)
    return ax
wxchan's avatar
wxchan committed
169
170


171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
def plot_split_value_histogram(
    booster: Union[Booster, LGBMModel],
    feature: Union[int, str],
    bins: Union[int, str, None] = None,
    ax=None,
    width_coef: float = 0.8,
    xlim: Optional[Tuple[float, float]] = None,
    ylim: Optional[Tuple[float, float]] = None,
    title: Optional[str] = 'Split value histogram for feature with @index/name@ @feature@',
    xlabel: Optional[str] = 'Feature split value',
    ylabel: Optional[str] = 'Count',
    figsize: Optional[Tuple[float, float]] = None,
    dpi: Optional[int] = None,
    grid: bool = True,
    **kwargs: Any
) -> Any:
187
188
189
190
191
192
    """Plot split value histogram for the specified feature of the model.

    Parameters
    ----------
    booster : Booster or LGBMModel
        Booster or LGBMModel instance of which feature split value histogram should be plotted.
193
    feature : int or str
194
195
        The feature name or index the histogram is plotted for.
        If int, interpreted as index.
196
197
        If str, interpreted as name.
    bins : int, str or None, optional (default=None)
198
199
        The maximum number of bins.
        If None, the number of bins equals number of unique split values.
200
        If str, it should be one from the list of the supported values by ``numpy.histogram()`` function.
201
202
203
204
205
206
207
208
209
    ax : matplotlib.axes.Axes or None, optional (default=None)
        Target axes instance.
        If None, new figure and axes will be created.
    width_coef : float, optional (default=0.8)
        Coefficient for histogram bar width.
    xlim : tuple of 2 elements or None, optional (default=None)
        Tuple passed to ``ax.xlim()``.
    ylim : tuple of 2 elements or None, optional (default=None)
        Tuple passed to ``ax.ylim()``.
210
    title : str or None, optional (default="Split value histogram for feature with @index/name@ @feature@")
211
212
213
214
215
        Axes title.
        If None, title is disabled.
        @feature@ placeholder can be used, and it will be replaced with the value of ``feature`` parameter.
        @index/name@ placeholder can be used,
        and it will be replaced with ``index`` word in case of ``int`` type ``feature`` parameter
216
217
        or ``name`` word in case of ``str`` type ``feature`` parameter.
    xlabel : str or None, optional (default="Feature split value")
218
219
        X-axis title label.
        If None, title is disabled.
220
    ylabel : str or None, optional (default="Count")
221
222
223
224
        Y-axis title label.
        If None, title is disabled.
    figsize : tuple of 2 elements or None, optional (default=None)
        Figure size.
225
226
    dpi : int or None, optional (default=None)
        Resolution of the figure.
227
228
229
230
231
232
233
234
235
236
237
238
239
240
    grid : bool, optional (default=True)
        Whether to add a grid for axes.
    **kwargs
        Other parameters passed to ``ax.bar()``.

    Returns
    -------
    ax : matplotlib.axes.Axes
        The plot with specified model's feature split value histogram.
    """
    if MATPLOTLIB_INSTALLED:
        import matplotlib.pyplot as plt
        from matplotlib.ticker import MaxNLocator
    else:
241
        raise ImportError('You must install matplotlib and restart your session to plot split value histogram.')
242
243
244
245
246
247

    if isinstance(booster, LGBMModel):
        booster = booster.booster_
    elif not isinstance(booster, Booster):
        raise TypeError('booster must be Booster or LGBMModel.')

248
    hist, split_bins = booster.get_split_value_histogram(feature=feature, bins=bins, xgboost_style=False)
249
250
    if np.count_nonzero(hist) == 0:
        raise ValueError('Cannot plot split value histogram, '
251
                         f'because feature {feature} was not used in splitting')
252
253
    width = width_coef * (split_bins[1] - split_bins[0])
    centred = (split_bins[:-1] + split_bins[1:]) / 2
254
255
256
257

    if ax is None:
        if figsize is not None:
            _check_not_tuple_of_2_elements(figsize, 'figsize')
258
        _, ax = plt.subplots(1, 1, figsize=figsize, dpi=dpi)
259
260
261
262
263
264

    ax.bar(centred, hist, align='center', width=width, **kwargs)

    if xlim is not None:
        _check_not_tuple_of_2_elements(xlim, 'xlim')
    else:
265
266
        range_result = split_bins[-1] - split_bins[0]
        xlim = (split_bins[0] - range_result * 0.2, split_bins[-1] + range_result * 0.2)
267
268
269
270
271
272
273
274
275
276
277
    ax.set_xlim(xlim)

    ax.yaxis.set_major_locator(MaxNLocator(integer=True))
    if ylim is not None:
        _check_not_tuple_of_2_elements(ylim, 'ylim')
    else:
        ylim = (0, max(hist) * 1.1)
    ax.set_ylim(ylim)

    if title is not None:
        title = title.replace('@feature@', str(feature))
278
        title = title.replace('@index/name@', ('name' if isinstance(feature, str) else 'index'))
279
280
281
282
283
284
285
286
287
        ax.set_title(title)
    if xlabel is not None:
        ax.set_xlabel(xlabel)
    if ylabel is not None:
        ax.set_ylabel(ylabel)
    ax.grid(grid)
    return ax


288
289
290
291
292
293
294
295
296
def plot_metric(
    booster: Union[Dict, LGBMModel],
    metric: Optional[str] = None,
    dataset_names: Optional[List[str]] = None,
    ax=None,
    xlim: Optional[Tuple[float, float]] = None,
    ylim: Optional[Tuple[float, float]] = None,
    title: Optional[str] = 'Metric during training',
    xlabel: Optional[str] = 'Iterations',
297
    ylabel: Optional[str] = '@metric@',
298
299
300
301
    figsize: Optional[Tuple[float, float]] = None,
    dpi: Optional[int] = None,
    grid: bool = True
) -> Any:
302
303
304
305
306
    """Plot one metric during training.

    Parameters
    ----------
    booster : dict or LGBMModel
307
        Dictionary returned from ``lightgbm.train()`` or LGBMModel instance.
308
    metric : str or None, optional (default=None)
309
310
        The metric name to plot.
        Only one metric supported because different metrics have various scales.
311
        If None, first metric picked from dictionary (according to hashcode).
312
    dataset_names : list of str, or None, optional (default=None)
313
314
315
316
317
318
319
320
321
        List of the dataset names which are used to calculate metric to plot.
        If None, all datasets are used.
    ax : matplotlib.axes.Axes or None, optional (default=None)
        Target axes instance.
        If None, new figure and axes will be created.
    xlim : tuple of 2 elements or None, optional (default=None)
        Tuple passed to ``ax.xlim()``.
    ylim : tuple of 2 elements or None, optional (default=None)
        Tuple passed to ``ax.ylim()``.
322
    title : str or None, optional (default="Metric during training")
323
324
        Axes title.
        If None, title is disabled.
325
    xlabel : str or None, optional (default="Iterations")
326
327
        X-axis title label.
        If None, title is disabled.
328
    ylabel : str or None, optional (default="@metric@")
329
330
331
        Y-axis title label.
        If 'auto', metric name is used.
        If None, title is disabled.
332
        @metric@ placeholder can be used, and it will be replaced with metric name.
333
334
    figsize : tuple of 2 elements or None, optional (default=None)
        Figure size.
335
336
    dpi : int or None, optional (default=None)
        Resolution of the figure.
337
338
    grid : bool, optional (default=True)
        Whether to add a grid for axes.
339
340
341

    Returns
    -------
342
343
    ax : matplotlib.axes.Axes
        The plot with metric's history over the training.
344
    """
345
    if MATPLOTLIB_INSTALLED:
346
        import matplotlib.pyplot as plt
347
    else:
348
        raise ImportError('You must install matplotlib and restart your session to plot metric.')
349
350
351
352
353

    if isinstance(booster, LGBMModel):
        eval_results = deepcopy(booster.evals_result_)
    elif isinstance(booster, dict):
        eval_results = deepcopy(booster)
354
355
    elif isinstance(booster, Booster):
        raise TypeError("booster must be dict or LGBMModel. To use plot_metric with Booster type, first record the metrics using record_evaluation callback then pass that to plot_metric as argument `booster`")
356
357
358
359
360
361
362
363
364
365
    else:
        raise TypeError('booster must be dict or LGBMModel.')

    num_data = len(eval_results)

    if not num_data:
        raise ValueError('eval results cannot be empty.')

    if ax is None:
        if figsize is not None:
366
            _check_not_tuple_of_2_elements(figsize, 'figsize')
367
        _, ax = plt.subplots(1, 1, figsize=figsize, dpi=dpi)
368
369

    if dataset_names is None:
370
        dataset_names_iter = iter(eval_results.keys())
371
372
373
    elif not isinstance(dataset_names, (list, tuple, set)) or not dataset_names:
        raise ValueError('dataset_names should be iterable and cannot be empty')
    else:
374
        dataset_names_iter = iter(dataset_names)
375

376
    name = next(dataset_names_iter)  # take one as sample
377
378
379
380
    metrics_for_one = eval_results[name]
    num_metric = len(metrics_for_one)
    if metric is None:
        if num_metric > 1:
381
            _log_warning("More than one metric available, picking one to plot.")
382
383
384
385
386
        metric, results = metrics_for_one.popitem()
    else:
        if metric not in metrics_for_one:
            raise KeyError('No given metric in eval results.')
        results = metrics_for_one[metric]
387
388
389
    num_iteration = len(results)
    max_result = max(results)
    min_result = min(results)
390
    x_ = range(num_iteration)
391
392
    ax.plot(x_, results, label=name)

393
    for name in dataset_names_iter:
394
395
        metrics_for_one = eval_results[name]
        results = metrics_for_one[metric]
396
397
        max_result = max(max(results), max_result)
        min_result = min(min(results), min_result)
398
399
400
401
402
        ax.plot(x_, results, label=name)

    ax.legend(loc='best')

    if xlim is not None:
403
        _check_not_tuple_of_2_elements(xlim, 'xlim')
404
405
406
407
408
    else:
        xlim = (0, num_iteration)
    ax.set_xlim(xlim)

    if ylim is not None:
409
        _check_not_tuple_of_2_elements(ylim, 'ylim')
410
411
412
413
414
415
416
417
418
419
    else:
        range_result = max_result - min_result
        ylim = (min_result - range_result * 0.2, max_result + range_result * 0.2)
    ax.set_ylim(ylim)

    if title is not None:
        ax.set_title(title)
    if xlabel is not None:
        ax.set_xlabel(xlabel)
    if ylabel is not None:
420
        ylabel = ylabel.replace('@metric@', metric)
421
422
423
424
425
        ax.set_ylabel(ylabel)
    ax.grid(grid)
    return ax


426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
def _determine_direction_for_numeric_split(
    fval: float,
    threshold: float,
    missing_type_str: str,
    default_left: bool,
) -> str:
    missing_type = _MissingType(missing_type_str)
    if math.isnan(fval) and missing_type != _MissingType.NAN:
        fval = 0.0
    if ((missing_type == _MissingType.ZERO and _is_zero(fval))
            or (missing_type == _MissingType.NAN and math.isnan(fval))):
        direction = 'left' if default_left else 'right'
    else:
        direction = 'left' if fval <= threshold else 'right'
    return direction


def _determine_direction_for_categorical_split(fval: float, thresholds: str) -> str:
    if math.isnan(fval) or int(fval) < 0:
        return 'right'
    int_thresholds = {int(t) for t in thresholds.split('||')}
    return 'left' if int(fval) in int_thresholds else 'right'


450
451
452
453
def _to_graphviz(
    tree_info: Dict[str, Any],
    show_info: List[str],
    feature_names: Union[List[str], None],
454
455
456
457
    precision: Optional[int],
    orientation: str,
    constraints: Optional[List[int]],
    example_case: Optional[Union[np.ndarray, pd_DataFrame]],
458
    max_category_values: int,
459
460
    **kwargs: Any
) -> Any:
461
462
463
    """Convert specified tree to graphviz instance.

    See:
464
      - https://graphviz.readthedocs.io/en/stable/api.html#digraph
465
    """
466
    if GRAPHVIZ_INSTALLED:
467
        from graphviz import Digraph
468
    else:
469
        raise ImportError('You must install graphviz and restart your session to plot tree.')
wxchan's avatar
wxchan committed
470

471
472
473
474
475
476
477
    def add(
        root: Dict[str, Any],
        total_count: int,
        parent: Optional[str],
        decision: Optional[str],
        highlight: bool
    ) -> None:
478
        """Recursively add node or edge."""
479
480
        fillcolor = 'white'
        style = ''
481
        tooltip = None
482
483
484
485
486
487
        if highlight:
            color = 'blue'
            penwidth = '3'
        else:
            color = 'black'
            penwidth = '1'
wxchan's avatar
wxchan committed
488
        if 'split_index' in root:  # non-leaf
489
            shape = "rectangle"
490
491
            l_dec = 'yes'
            r_dec = 'no'
492
            threshold = root['threshold']
493
            if root['decision_type'] == '<=':
494
                operator = "&#8804;"
495
            elif root['decision_type'] == '==':
496
                operator = "="
497
498
            else:
                raise ValueError('Invalid decision type in tree model.')
499
            name = f"split{root['split_index']}"
500
            split_feature = root['split_feature']
501
            if feature_names is not None:
502
                label = f"<B>{feature_names[split_feature]}</B> {operator}"
503
            else:
504
505
506
507
                label = f"feature <B>{split_feature}</B> {operator} "
            direction = None
            if example_case is not None:
                if root['decision_type'] == '==':
508
509
510
511
                    direction = _determine_direction_for_categorical_split(
                        fval=example_case[split_feature],
                        thresholds=root['threshold']
                    )
512
513
                else:
                    direction = _determine_direction_for_numeric_split(
514
515
516
517
                        fval=example_case[split_feature],
                        threshold=root['threshold'],
                        missing_type_str=root['missing_type'],
                        default_left=root['default_left']
518
                    )
519
520
521
522
523
524
525
            if root['decision_type'] == '==':
                category_values = root['threshold'].split('||')
                if len(category_values) > max_category_values:
                    tooltip = root['threshold']
                    threshold = '||'.join(category_values[:2]) + '||...||' + category_values[-1]

            label += f"<B>{_float2str(threshold, precision)}</B>"
526
527
528
529
            for info in ['split_gain', 'internal_value', 'internal_weight', "internal_count", "data_percentage"]:
                if info in show_info:
                    output = info.split('_')[-1]
                    if info in {'split_gain', 'internal_value', 'internal_weight'}:
530
                        label += f"<br/>{_float2str(root[info], precision)} {output}"
531
                    elif info == 'internal_count':
532
                        label += f"<br/>{output}: {root[info]}"
533
                    elif info == "data_percentage":
534
                        label += f"<br/>{_float2str(root['internal_count'] / total_count * 100, 2)}% of data"
535
536
537
538
539
540
541

            if constraints:
                if constraints[root['split_feature']] == 1:
                    fillcolor = "#ddffdd"  # light green
                if constraints[root['split_feature']] == -1:
                    fillcolor = "#ffdddd"  # light red
                style = "filled"
542
            label = f"<{label}>"
543
544
545
546
547
548
549
550
551
552
553
554
555
556
            add(
                root=root['left_child'],
                total_count=total_count,
                parent=name,
                decision=l_dec,
                highlight=highlight and direction == "left"
            )
            add(
                root=root['right_child'],
                total_count=total_count,
                parent=name,
                decision=r_dec,
                highlight=highlight and direction == "right"
            )
wxchan's avatar
wxchan committed
557
        else:  # leaf
558
            shape = "ellipse"
559
560
561
            name = f"leaf{root['leaf_index']}"
            label = f"leaf {root['leaf_index']}: "
            label += f"<B>{_float2str(root['leaf_value'], precision)}</B>"
562
            if 'leaf_weight' in show_info:
563
                label += f"<br/>{_float2str(root['leaf_weight'], precision)} weight"
564
            if 'leaf_count' in show_info:
565
                label += f"<br/>count: {root['leaf_count']}"
566
            if "data_percentage" in show_info:
567
568
                label += f"<br/>{_float2str(root['leaf_count'] / total_count * 100, 2)}% of data"
            label = f"<{label}>"
569
        graph.node(name, label=label, shape=shape, style=style, fillcolor=fillcolor, color=color, penwidth=penwidth, tooltip=tooltip)
wxchan's avatar
wxchan committed
570
        if parent is not None:
571
            graph.edge(parent, name, decision, color=color, penwidth=penwidth)
wxchan's avatar
wxchan committed
572

573
    graph = Digraph(**kwargs)
574
575
    rankdir = "LR" if orientation == "horizontal" else "TB"
    graph.attr("graph", nodesep="0.05", ranksep="0.3", rankdir=rankdir)
576
    if "internal_count" in tree_info['tree_structure']:
577
578
579
580
581
582
583
        add(
            root=tree_info['tree_structure'],
            total_count=tree_info['tree_structure']["internal_count"],
            parent=None,
            decision=None,
            highlight=example_case is not None
        )
584
    else:
585
        raise Exception("Cannot plot trees with no split")
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604

    if constraints:
        # "#ddffdd" is light green, "#ffdddd" is light red
        legend = """<
            <TABLE BORDER="0" CELLBORDER="1" CELLSPACING="0" CELLPADDING="4">
             <TR>
              <TD COLSPAN="2"><B>Monotone constraints</B></TD>
             </TR>
             <TR>
              <TD>Increasing</TD>
              <TD BGCOLOR="#ddffdd"></TD>
             </TR>
             <TR>
              <TD>Decreasing</TD>
              <TD BGCOLOR="#ffdddd"></TD>
             </TR>
            </TABLE>
           >"""
        graph.node("legend", label=legend, shape="rectangle", color="white")
605
606
607
    return graph


608
609
610
611
612
613
def create_tree_digraph(
    booster: Union[Booster, LGBMModel],
    tree_index: int = 0,
    show_info: Optional[List[str]] = None,
    precision: Optional[int] = 3,
    orientation: str = 'horizontal',
614
    example_case: Optional[Union[np.ndarray, pd_DataFrame]] = None,
615
    max_category_values: int = 10,
616
617
    **kwargs: Any
) -> Any:
618
    """Create a digraph representation of specified tree.
619

620
621
622
623
624
625
626
627
628
629
    Each node in the graph represents a node in the tree.

    Non-leaf nodes have labels like ``Column_10 <= 875.9``, which means
    "this node splits on the feature named "Column_10", with threshold 875.9".

    Leaf nodes have labels like ``leaf 2: 0.422``, which means "this node is a
    leaf node, and the predicted value for records that fall into this node
    is 0.422". The number (``2``) is an internal unique identifier and doesn't
    have any special meaning.

Nikita Titov's avatar
Nikita Titov committed
630
631
632
633
    .. note::

        For more information please visit
        https://graphviz.readthedocs.io/en/stable/api.html#digraph.
634

635
636
    Parameters
    ----------
637
    booster : Booster or LGBMModel
638
        Booster or LGBMModel instance to be converted.
639
640
    tree_index : int, optional (default=0)
        The index of a target tree to convert.
641
    show_info : list of str, or None, optional (default=None)
642
        What information should be shown in nodes.
643
644
645
646
647
648

            - ``'split_gain'`` : gain from adding this split to the model
            - ``'internal_value'`` : raw predicted value that would be produced by this node if it was a leaf node
            - ``'internal_count'`` : number of records from the training data that fall into this non-leaf node
            - ``'internal_weight'`` : total weight of all nodes that fall into this non-leaf node
            - ``'leaf_count'`` : number of records from the training data that fall into this leaf node
649
            - ``'leaf_weight'`` : total weight (sum of Hessian) of all observations that fall into this leaf node
650
            - ``'data_percentage'`` : percentage of training data that fall into this node
651
    precision : int or None, optional (default=3)
652
        Used to restrict the display of floating point values to a certain precision.
653
    orientation : str, optional (default='horizontal')
654
655
        Orientation of the tree.
        Can be 'horizontal' or 'vertical'.
656
657
658
    example_case : numpy 2-D array, pandas DataFrame or None, optional (default=None)
        Single row with the same structure as the training data.
        If not None, the plot will highlight the path that sample takes through the tree.
659
660
661

        .. versionadded:: 4.0.0

662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
    max_category_values : int, optional (default=10)
        The maximum number of category values to display in tree nodes, if the number of thresholds is greater than this value, thresholds will be collapsed and displayed on the label tooltip instead.

        .. warning::

            Consider wrapping the SVG string of the tree graph with ``IPython.display.HTML`` when running on JupyterLab to get the `tooltip <https://graphviz.org/docs/attrs/tooltip>`_ working right.

            Example:

            .. code-block:: python

                from IPython.display import HTML

                graph = lgb.create_tree_digraph(clf, max_category_values=5)
                HTML(graph._repr_image_svg_xml())

678
679
        .. versionadded:: 4.0.0

680
    **kwargs
681
682
        Other parameters passed to ``Digraph`` constructor.
        Check https://graphviz.readthedocs.io/en/stable/api.html#digraph for the full list of supported parameters.
683
684
685

    Returns
    -------
686
687
    graph : graphviz.Digraph
        The digraph representation of specified tree.
688
689
690
691
692
693
694
695
696
697
698
699
700
    """
    if isinstance(booster, LGBMModel):
        booster = booster.booster_
    elif not isinstance(booster, Booster):
        raise TypeError('booster must be Booster or LGBMModel.')

    model = booster.dump_model()
    tree_infos = model['tree_info']
    if 'feature_names' in model:
        feature_names = model['feature_names']
    else:
        feature_names = None

701
702
    monotone_constraints = model.get('monotone_constraints', None)

703
704
705
706
707
708
709
710
    if tree_index < len(tree_infos):
        tree_info = tree_infos[tree_index]
    else:
        raise IndexError('tree_index is out of range.')

    if show_info is None:
        show_info = []

711
712
713
714
715
716
    if example_case is not None:
        if not isinstance(example_case, (np.ndarray, pd_DataFrame)) or example_case.ndim != 2:
            raise ValueError('example_case must be a numpy 2-D array or a pandas DataFrame')
        if example_case.shape[0] != 1:
            raise ValueError('example_case must have a single row.')
        if isinstance(example_case, pd_DataFrame):
717
718
719
720
721
722
            example_case = _data_from_pandas(
                data=example_case,
                feature_name=None,
                categorical_feature=None,
                pandas_categorical=booster.pandas_categorical
            )[0]
723
724
        example_case = example_case[0]

725
726
727
728
729
730
731
732
    graph = _to_graphviz(
        tree_info=tree_info,
        show_info=show_info,
        feature_names=feature_names,
        precision=precision,
        orientation=orientation,
        constraints=monotone_constraints,
        example_case=example_case,
733
        max_category_values=max_category_values,
734
735
        **kwargs
    )
736

wxchan's avatar
wxchan committed
737
738
739
    return graph


740
741
742
743
744
745
746
747
748
def plot_tree(
    booster: Union[Booster, LGBMModel],
    ax=None,
    tree_index: int = 0,
    figsize: Optional[Tuple[float, float]] = None,
    dpi: Optional[int] = None,
    show_info: Optional[List[str]] = None,
    precision: Optional[int] = 3,
    orientation: str = 'horizontal',
749
    example_case: Optional[Union[np.ndarray, pd_DataFrame]] = None,
750
751
    **kwargs: Any
) -> Any:
wxchan's avatar
wxchan committed
752
753
    """Plot specified tree.

754
755
756
757
758
759
760
761
762
763
    Each node in the graph represents a node in the tree.

    Non-leaf nodes have labels like ``Column_10 <= 875.9``, which means
    "this node splits on the feature named "Column_10", with threshold 875.9".

    Leaf nodes have labels like ``leaf 2: 0.422``, which means "this node is a
    leaf node, and the predicted value for records that fall into this node
    is 0.422". The number (``2``) is an internal unique identifier and doesn't
    have any special meaning.

Nikita Titov's avatar
Nikita Titov committed
764
765
766
767
    .. note::

        It is preferable to use ``create_tree_digraph()`` because of its lossless quality
        and returned objects can be also rendered and displayed directly inside a Jupyter notebook.
768

wxchan's avatar
wxchan committed
769
770
    Parameters
    ----------
771
772
773
774
775
776
777
778
    booster : Booster or LGBMModel
        Booster or LGBMModel instance to be plotted.
    ax : matplotlib.axes.Axes or None, optional (default=None)
        Target axes instance.
        If None, new figure and axes will be created.
    tree_index : int, optional (default=0)
        The index of a target tree to plot.
    figsize : tuple of 2 elements or None, optional (default=None)
wxchan's avatar
wxchan committed
779
        Figure size.
780
781
    dpi : int or None, optional (default=None)
        Resolution of the figure.
782
    show_info : list of str, or None, optional (default=None)
783
        What information should be shown in nodes.
784
785
786
787
788
789

            - ``'split_gain'`` : gain from adding this split to the model
            - ``'internal_value'`` : raw predicted value that would be produced by this node if it was a leaf node
            - ``'internal_count'`` : number of records from the training data that fall into this non-leaf node
            - ``'internal_weight'`` : total weight of all nodes that fall into this non-leaf node
            - ``'leaf_count'`` : number of records from the training data that fall into this leaf node
790
            - ``'leaf_weight'`` : total weight (sum of Hessian) of all observations that fall into this leaf node
791
            - ``'data_percentage'`` : percentage of training data that fall into this node
792
    precision : int or None, optional (default=3)
793
        Used to restrict the display of floating point values to a certain precision.
794
    orientation : str, optional (default='horizontal')
795
796
        Orientation of the tree.
        Can be 'horizontal' or 'vertical'.
797
798
799
    example_case : numpy 2-D array, pandas DataFrame or None, optional (default=None)
        Single row with the same structure as the training data.
        If not None, the plot will highlight the path that sample takes through the tree.
800
801
802

        .. versionadded:: 4.0.0

803
    **kwargs
804
805
        Other parameters passed to ``Digraph`` constructor.
        Check https://graphviz.readthedocs.io/en/stable/api.html#digraph for the full list of supported parameters.
wxchan's avatar
wxchan committed
806
807
808

    Returns
    -------
809
810
    ax : matplotlib.axes.Axes
        The plot with single tree.
wxchan's avatar
wxchan committed
811
    """
812
    if MATPLOTLIB_INSTALLED:
wxchan's avatar
wxchan committed
813
        import matplotlib.image as image
814
        import matplotlib.pyplot as plt
815
    else:
816
        raise ImportError('You must install matplotlib and restart your session to plot tree.')
wxchan's avatar
wxchan committed
817
818

    if ax is None:
819
        if figsize is not None:
820
            _check_not_tuple_of_2_elements(figsize, 'figsize')
821
        _, ax = plt.subplots(1, 1, figsize=figsize, dpi=dpi)
wxchan's avatar
wxchan committed
822

823
    graph = create_tree_digraph(booster=booster, tree_index=tree_index,
824
                                show_info=show_info, precision=precision,
825
                                orientation=orientation, example_case=example_case, **kwargs)
wxchan's avatar
wxchan committed
826
827

    s = BytesIO()
828
    s.write(graph.pipe(format='png'))
wxchan's avatar
wxchan committed
829
830
831
832
833
834
    s.seek(0)
    img = image.imread(s)

    ax.imshow(img)
    ax.axis('off')
    return ax