plotting.py 29.5 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 = 'obj') -> 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] = None) -> 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
454
455
456
def _to_graphviz(
    tree_info: Dict[str, Any],
    show_info: List[str],
    feature_names: Union[List[str], None],
    precision: Optional[int] = 3,
    orientation: str = 'horizontal',
    constraints: Optional[List[int]] = None,
457
    example_case: Optional[Union[np.ndarray, pd_DataFrame]] = None,
458
459
    **kwargs: Any
) -> Any:
460
461
462
    """Convert specified tree to graphviz instance.

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

470
    def add(root, total_count, parent=None, decision=None, highlight=False):
471
        """Recursively add node or edge."""
472
473
474
475
476
477
478
479
        fillcolor = 'white'
        style = ''
        if highlight:
            color = 'blue'
            penwidth = '3'
        else:
            color = 'black'
            penwidth = '1'
wxchan's avatar
wxchan committed
480
        if 'split_index' in root:  # non-leaf
481
            shape = "rectangle"
482
483
            l_dec = 'yes'
            r_dec = 'no'
484
            if root['decision_type'] == '<=':
485
                operator = "&#8804;"
486
            elif root['decision_type'] == '==':
487
                operator = "="
488
489
            else:
                raise ValueError('Invalid decision type in tree model.')
490
            name = f"split{root['split_index']}"
491
            split_feature = root['split_feature']
492
            if feature_names is not None:
493
                label = f"<B>{feature_names[split_feature]}</B> {operator}"
494
            else:
495
496
497
498
499
500
501
502
503
                label = f"feature <B>{split_feature}</B> {operator} "
            direction = None
            if example_case is not None:
                if root['decision_type'] == '==':
                    direction = _determine_direction_for_categorical_split(example_case[split_feature], root['threshold'])
                else:
                    direction = _determine_direction_for_numeric_split(
                        example_case[split_feature], root['threshold'], root['missing_type'], root['default_left']
                    )
504
            label += f"<B>{_float2str(root['threshold'], precision)}</B>"
505
506
507
508
            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'}:
509
                        label += f"<br/>{_float2str(root[info], precision)} {output}"
510
                    elif info == 'internal_count':
511
                        label += f"<br/>{output}: {root[info]}"
512
                    elif info == "data_percentage":
513
                        label += f"<br/>{_float2str(root['internal_count'] / total_count * 100, 2)}% of data"
514
515
516
517
518
519
520

            if constraints:
                if constraints[root['split_feature']] == 1:
                    fillcolor = "#ddffdd"  # light green
                if constraints[root['split_feature']] == -1:
                    fillcolor = "#ffdddd"  # light red
                style = "filled"
521
            label = f"<{label}>"
522
523
            add(root['left_child'], total_count, name, l_dec, highlight and direction == "left")
            add(root['right_child'], total_count, name, r_dec, highlight and direction == "right")
wxchan's avatar
wxchan committed
524
        else:  # leaf
525
            shape = "ellipse"
526
527
528
            name = f"leaf{root['leaf_index']}"
            label = f"leaf {root['leaf_index']}: "
            label += f"<B>{_float2str(root['leaf_value'], precision)}</B>"
529
            if 'leaf_weight' in show_info:
530
                label += f"<br/>{_float2str(root['leaf_weight'], precision)} weight"
531
            if 'leaf_count' in show_info:
532
                label += f"<br/>count: {root['leaf_count']}"
533
            if "data_percentage" in show_info:
534
535
                label += f"<br/>{_float2str(root['leaf_count'] / total_count * 100, 2)}% of data"
            label = f"<{label}>"
536
        graph.node(name, label=label, shape=shape, style=style, fillcolor=fillcolor, color=color, penwidth=penwidth)
wxchan's avatar
wxchan committed
537
        if parent is not None:
538
            graph.edge(parent, name, decision, color=color, penwidth=penwidth)
wxchan's avatar
wxchan committed
539

540
    graph = Digraph(**kwargs)
541
542
    rankdir = "LR" if orientation == "horizontal" else "TB"
    graph.attr("graph", nodesep="0.05", ranksep="0.3", rankdir=rankdir)
543
    if "internal_count" in tree_info['tree_structure']:
544
        add(tree_info['tree_structure'], tree_info['tree_structure']["internal_count"], highlight=example_case is not None)
545
    else:
546
        raise Exception("Cannot plot trees with no split")
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565

    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")
566
567
568
    return graph


569
570
571
572
573
574
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',
575
    example_case: Optional[Union[np.ndarray, pd_DataFrame]] = None,
576
577
    **kwargs: Any
) -> Any:
578
    """Create a digraph representation of specified tree.
579

580
581
582
583
584
585
586
587
588
589
    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
590
591
592
593
    .. note::

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

595
596
    Parameters
    ----------
597
    booster : Booster or LGBMModel
598
        Booster or LGBMModel instance to be converted.
599
600
    tree_index : int, optional (default=0)
        The index of a target tree to convert.
601
    show_info : list of str, or None, optional (default=None)
602
        What information should be shown in nodes.
603
604
605
606
607
608

            - ``'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
609
            - ``'leaf_weight'`` : total weight (sum of Hessian) of all observations that fall into this leaf node
610
            - ``'data_percentage'`` : percentage of training data that fall into this node
611
    precision : int or None, optional (default=3)
612
        Used to restrict the display of floating point values to a certain precision.
613
    orientation : str, optional (default='horizontal')
614
615
        Orientation of the tree.
        Can be 'horizontal' or 'vertical'.
616
617
618
    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.
619
    **kwargs
620
621
        Other parameters passed to ``Digraph`` constructor.
        Check https://graphviz.readthedocs.io/en/stable/api.html#digraph for the full list of supported parameters.
622
623
624

    Returns
    -------
625
626
    graph : graphviz.Digraph
        The digraph representation of specified tree.
627
628
629
630
631
632
633
634
635
636
637
638
639
    """
    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

640
641
    monotone_constraints = model.get('monotone_constraints', None)

642
643
644
645
646
647
648
649
    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 = []

650
651
652
653
654
655
656
657
658
    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):
            example_case = _data_from_pandas(example_case, None, None, booster.pandas_categorical)[0]
        example_case = example_case[0]

659
    graph = _to_graphviz(tree_info, show_info, feature_names, precision,
660
                         orientation, monotone_constraints, example_case=example_case, **kwargs)
661

wxchan's avatar
wxchan committed
662
663
664
    return graph


665
666
667
668
669
670
671
672
673
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',
674
    example_case: Optional[Union[np.ndarray, pd_DataFrame]] = None,
675
676
    **kwargs: Any
) -> Any:
wxchan's avatar
wxchan committed
677
678
    """Plot specified tree.

679
680
681
682
683
684
685
686
687
688
    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
689
690
691
692
    .. 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.
693

wxchan's avatar
wxchan committed
694
695
    Parameters
    ----------
696
697
698
699
700
701
702
703
    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
704
        Figure size.
705
706
    dpi : int or None, optional (default=None)
        Resolution of the figure.
707
    show_info : list of str, or None, optional (default=None)
708
        What information should be shown in nodes.
709
710
711
712
713
714

            - ``'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
715
            - ``'leaf_weight'`` : total weight (sum of Hessian) of all observations that fall into this leaf node
716
            - ``'data_percentage'`` : percentage of training data that fall into this node
717
    precision : int or None, optional (default=3)
718
        Used to restrict the display of floating point values to a certain precision.
719
    orientation : str, optional (default='horizontal')
720
721
        Orientation of the tree.
        Can be 'horizontal' or 'vertical'.
722
723
724
    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.
725
    **kwargs
726
727
        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
728
729
730

    Returns
    -------
731
732
    ax : matplotlib.axes.Axes
        The plot with single tree.
wxchan's avatar
wxchan committed
733
    """
734
    if MATPLOTLIB_INSTALLED:
wxchan's avatar
wxchan committed
735
        import matplotlib.image as image
736
        import matplotlib.pyplot as plt
737
    else:
738
        raise ImportError('You must install matplotlib and restart your session to plot tree.')
wxchan's avatar
wxchan committed
739
740

    if ax is None:
741
        if figsize is not None:
742
            _check_not_tuple_of_2_elements(figsize, 'figsize')
743
        _, ax = plt.subplots(1, 1, figsize=figsize, dpi=dpi)
wxchan's avatar
wxchan committed
744

745
    graph = create_tree_digraph(booster=booster, tree_index=tree_index,
746
                                show_info=show_info, precision=precision,
747
                                orientation=orientation, example_case=example_case, **kwargs)
wxchan's avatar
wxchan committed
748
749

    s = BytesIO()
750
    s.write(graph.pipe(format='png'))
wxchan's avatar
wxchan committed
751
752
753
754
755
756
    s.seek(0)
    img = image.imread(s)

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