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

            if constraints:
                if constraints[root['split_feature']] == 1:
                    fillcolor = "#ddffdd"  # light green
                if constraints[root['split_feature']] == -1:
                    fillcolor = "#ffdddd"  # light red
                style = "filled"
533
            label = f"<{label}>"
534
535
536
537
538
539
540
541
542
543
544
545
546
547
            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
548
        else:  # leaf
549
            shape = "ellipse"
550
551
552
            name = f"leaf{root['leaf_index']}"
            label = f"leaf {root['leaf_index']}: "
            label += f"<B>{_float2str(root['leaf_value'], precision)}</B>"
553
            if 'leaf_weight' in show_info:
554
                label += f"<br/>{_float2str(root['leaf_weight'], precision)} weight"
555
            if 'leaf_count' in show_info:
556
                label += f"<br/>count: {root['leaf_count']}"
557
            if "data_percentage" in show_info:
558
559
                label += f"<br/>{_float2str(root['leaf_count'] / total_count * 100, 2)}% of data"
            label = f"<{label}>"
560
        graph.node(name, label=label, shape=shape, style=style, fillcolor=fillcolor, color=color, penwidth=penwidth)
wxchan's avatar
wxchan committed
561
        if parent is not None:
562
            graph.edge(parent, name, decision, color=color, penwidth=penwidth)
wxchan's avatar
wxchan committed
563

564
    graph = Digraph(**kwargs)
565
566
    rankdir = "LR" if orientation == "horizontal" else "TB"
    graph.attr("graph", nodesep="0.05", ranksep="0.3", rankdir=rankdir)
567
    if "internal_count" in tree_info['tree_structure']:
568
569
570
571
572
573
574
        add(
            root=tree_info['tree_structure'],
            total_count=tree_info['tree_structure']["internal_count"],
            parent=None,
            decision=None,
            highlight=example_case is not None
        )
575
    else:
576
        raise Exception("Cannot plot trees with no split")
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595

    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")
596
597
598
    return graph


599
600
601
602
603
604
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',
605
    example_case: Optional[Union[np.ndarray, pd_DataFrame]] = None,
606
607
    **kwargs: Any
) -> Any:
608
    """Create a digraph representation of specified tree.
609

610
611
612
613
614
615
616
617
618
619
    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
620
621
622
623
    .. note::

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

625
626
    Parameters
    ----------
627
    booster : Booster or LGBMModel
628
        Booster or LGBMModel instance to be converted.
629
630
    tree_index : int, optional (default=0)
        The index of a target tree to convert.
631
    show_info : list of str, or None, optional (default=None)
632
        What information should be shown in nodes.
633
634
635
636
637
638

            - ``'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
639
            - ``'leaf_weight'`` : total weight (sum of Hessian) of all observations that fall into this leaf node
640
            - ``'data_percentage'`` : percentage of training data that fall into this node
641
    precision : int or None, optional (default=3)
642
        Used to restrict the display of floating point values to a certain precision.
643
    orientation : str, optional (default='horizontal')
644
645
        Orientation of the tree.
        Can be 'horizontal' or 'vertical'.
646
647
648
    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.
649
    **kwargs
650
651
        Other parameters passed to ``Digraph`` constructor.
        Check https://graphviz.readthedocs.io/en/stable/api.html#digraph for the full list of supported parameters.
652
653
654

    Returns
    -------
655
656
    graph : graphviz.Digraph
        The digraph representation of specified tree.
657
658
659
660
661
662
663
664
665
666
667
668
669
    """
    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

670
671
    monotone_constraints = model.get('monotone_constraints', None)

672
673
674
675
676
677
678
679
    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 = []

680
681
682
683
684
685
    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):
686
687
688
689
690
691
            example_case = _data_from_pandas(
                data=example_case,
                feature_name=None,
                categorical_feature=None,
                pandas_categorical=booster.pandas_categorical
            )[0]
692
693
        example_case = example_case[0]

694
695
696
697
698
699
700
701
702
703
    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,
        **kwargs
    )
704

wxchan's avatar
wxchan committed
705
706
707
    return graph


708
709
710
711
712
713
714
715
716
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',
717
    example_case: Optional[Union[np.ndarray, pd_DataFrame]] = None,
718
719
    **kwargs: Any
) -> Any:
wxchan's avatar
wxchan committed
720
721
    """Plot specified tree.

722
723
724
725
726
727
728
729
730
731
    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
732
733
734
735
    .. 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.
736

wxchan's avatar
wxchan committed
737
738
    Parameters
    ----------
739
740
741
742
743
744
745
746
    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
747
        Figure size.
748
749
    dpi : int or None, optional (default=None)
        Resolution of the figure.
750
    show_info : list of str, or None, optional (default=None)
751
        What information should be shown in nodes.
752
753
754
755
756
757

            - ``'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
758
            - ``'leaf_weight'`` : total weight (sum of Hessian) of all observations that fall into this leaf node
759
            - ``'data_percentage'`` : percentage of training data that fall into this node
760
    precision : int or None, optional (default=3)
761
        Used to restrict the display of floating point values to a certain precision.
762
    orientation : str, optional (default='horizontal')
763
764
        Orientation of the tree.
        Can be 'horizontal' or 'vertical'.
765
766
767
    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.
768
    **kwargs
769
770
        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
771
772
773

    Returns
    -------
774
775
    ax : matplotlib.axes.Axes
        The plot with single tree.
wxchan's avatar
wxchan committed
776
    """
777
    if MATPLOTLIB_INSTALLED:
wxchan's avatar
wxchan committed
778
        import matplotlib.image as image
779
        import matplotlib.pyplot as plt
780
    else:
781
        raise ImportError('You must install matplotlib and restart your session to plot tree.')
wxchan's avatar
wxchan committed
782
783

    if ax is None:
784
        if figsize is not None:
785
            _check_not_tuple_of_2_elements(figsize, 'figsize')
786
        _, ax = plt.subplots(1, 1, figsize=figsize, dpi=dpi)
wxchan's avatar
wxchan committed
787

788
    graph = create_tree_digraph(booster=booster, tree_index=tree_index,
789
                                show_info=show_info, precision=precision,
790
                                orientation=orientation, example_case=example_case, **kwargs)
wxchan's avatar
wxchan committed
791
792

    s = BytesIO()
793
    s.write(graph.pipe(format='png'))
wxchan's avatar
wxchan committed
794
795
796
797
798
799
    s.seek(0)
    img = image.imread(s)

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