"...git@developer.sourcefind.cn:tianlh/lightgbm-dcu.git" did not exist on "1b43214f72e2f42c262f0c79eb35cd185a8654af"
plotting.py 31.6 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
__all__ = [
15
16
17
18
19
    "create_tree_digraph",
    "plot_importance",
    "plot_metric",
    "plot_split_value_histogram",
    "plot_tree",
20
21
]

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}" if precision is not None and not isinstance(value, str) else str(value)
31
32


33
34
35
36
37
38
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,
39
40
41
42
    title: Optional[str] = "Feature importance",
    xlabel: Optional[str] = "Feature importance",
    ylabel: Optional[str] = "Features",
    importance_type: str = "auto",
43
44
45
46
47
48
    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,
49
    **kwargs: Any,
50
) -> Any:
51
    """Plot model's feature importances.
52
53
54

    Parameters
    ----------
wxchan's avatar
wxchan committed
55
    booster : Booster or LGBMModel
56
57
58
59
60
61
62
63
64
65
        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()``.
66
    title : str or None, optional (default="Feature importance")
67
68
        Axes title.
        If None, title is disabled.
69
    xlabel : str or None, optional (default="Feature importance")
70
71
        X-axis title label.
        If None, title is disabled.
72
        @importance_type@ placeholder can be used, and it will be replaced with the value of ``importance_type`` parameter.
73
    ylabel : str or None, optional (default="Features")
74
75
        Y-axis title label.
        If None, title is disabled.
76
    importance_type : str, optional (default="auto")
77
        How the importance is calculated.
78
        If "auto", if ``booster`` parameter is LGBMModel, ``booster.importance_type`` attribute is used; "split" otherwise.
79
80
81
        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)
82
        Max number of top features displayed on plot.
83
84
85
86
87
        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.
88
89
    dpi : int or None, optional (default=None)
        Resolution of the figure.
90
91
    grid : bool, optional (default=True)
        Whether to add a grid for axes.
92
    precision : int or None, optional (default=3)
93
        Used to restrict the display of floating point values to a certain precision.
94
    **kwargs
95
        Other parameters passed to ``ax.barh()``.
96
97
98

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

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

    importance = booster.feature_importance(importance_type=importance_type)
    feature_name = booster.feature_name()
119
120

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

123
    tuples = sorted(zip(feature_name, importance), key=lambda x: x[1])
124
125
126
127
    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:]
128
    labels, values = zip(*tuples)
129
130

    if ax is None:
131
        if figsize is not None:
132
            _check_not_tuple_of_2_elements(figsize, "figsize")
133
        _, ax = plt.subplots(1, 1, figsize=figsize, dpi=dpi)
134
135

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

138
    for x, y in zip(values, ylocs):
139
        ax.text(x + 1, y, _float2str(x, precision) if importance_type == "gain" else x, va="center")
140
141
142
143
144

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

    if xlim is not None:
145
        _check_not_tuple_of_2_elements(xlim, "xlim")
146
147
148
149
150
    else:
        xlim = (0, max(values) * 1.1)
    ax.set_xlim(xlim)

    if ylim is not None:
151
        _check_not_tuple_of_2_elements(ylim, "ylim")
152
153
154
155
156
157
158
    else:
        ylim = (-1, len(values))
    ax.set_ylim(ylim)

    if title is not None:
        ax.set_title(title)
    if xlabel is not None:
159
        xlabel = xlabel.replace("@importance_type@", importance_type)
160
161
162
163
164
        ax.set_xlabel(xlabel)
    if ylabel is not None:
        ax.set_ylabel(ylabel)
    ax.grid(grid)
    return ax
wxchan's avatar
wxchan committed
165
166


167
168
169
170
171
172
173
174
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,
175
176
177
    title: Optional[str] = "Split value histogram for feature with @index/name@ @feature@",
    xlabel: Optional[str] = "Feature split value",
    ylabel: Optional[str] = "Count",
178
179
180
    figsize: Optional[Tuple[float, float]] = None,
    dpi: Optional[int] = None,
    grid: bool = True,
181
    **kwargs: Any,
182
) -> Any:
183
184
185
186
187
188
    """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.
189
    feature : int or str
190
191
        The feature name or index the histogram is plotted for.
        If int, interpreted as index.
192
193
        If str, interpreted as name.
    bins : int, str or None, optional (default=None)
194
195
        The maximum number of bins.
        If None, the number of bins equals number of unique split values.
196
        If str, it should be one from the list of the supported values by ``numpy.histogram()`` function.
197
198
199
200
201
202
203
204
205
    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()``.
206
    title : str or None, optional (default="Split value histogram for feature with @index/name@ @feature@")
207
208
209
210
211
        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
212
213
        or ``name`` word in case of ``str`` type ``feature`` parameter.
    xlabel : str or None, optional (default="Feature split value")
214
215
        X-axis title label.
        If None, title is disabled.
216
    ylabel : str or None, optional (default="Count")
217
218
219
220
        Y-axis title label.
        If None, title is disabled.
    figsize : tuple of 2 elements or None, optional (default=None)
        Figure size.
221
222
    dpi : int or None, optional (default=None)
        Resolution of the figure.
223
224
225
226
227
228
229
230
231
232
233
234
235
236
    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:
237
        raise ImportError("You must install matplotlib and restart your session to plot split value histogram.")
238
239
240
241

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

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

    if ax is None:
        if figsize is not None:
252
            _check_not_tuple_of_2_elements(figsize, "figsize")
253
        _, ax = plt.subplots(1, 1, figsize=figsize, dpi=dpi)
254

255
    ax.bar(centred, hist, align="center", width=width, **kwargs)
256
257

    if xlim is not None:
258
        _check_not_tuple_of_2_elements(xlim, "xlim")
259
    else:
260
261
        range_result = split_bins[-1] - split_bins[0]
        xlim = (split_bins[0] - range_result * 0.2, split_bins[-1] + range_result * 0.2)
262
263
264
265
    ax.set_xlim(xlim)

    ax.yaxis.set_major_locator(MaxNLocator(integer=True))
    if ylim is not None:
266
        _check_not_tuple_of_2_elements(ylim, "ylim")
267
268
269
270
271
    else:
        ylim = (0, max(hist) * 1.1)
    ax.set_ylim(ylim)

    if title is not None:
272
273
        title = title.replace("@feature@", str(feature))
        title = title.replace("@index/name@", ("name" if isinstance(feature, str) else "index"))
274
275
276
277
278
279
280
281
282
        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


283
284
285
286
287
288
289
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,
290
291
292
    title: Optional[str] = "Metric during training",
    xlabel: Optional[str] = "Iterations",
    ylabel: Optional[str] = "@metric@",
293
294
    figsize: Optional[Tuple[float, float]] = None,
    dpi: Optional[int] = None,
295
    grid: bool = True,
296
) -> Any:
297
298
299
300
301
    """Plot one metric during training.

    Parameters
    ----------
    booster : dict or LGBMModel
302
        Dictionary returned from ``lightgbm.train()`` or LGBMModel instance.
303
    metric : str or None, optional (default=None)
304
305
        The metric name to plot.
        Only one metric supported because different metrics have various scales.
306
        If None, first metric picked from dictionary (according to hashcode).
307
    dataset_names : list of str, or None, optional (default=None)
308
309
310
311
312
313
314
315
316
        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()``.
317
    title : str or None, optional (default="Metric during training")
318
319
        Axes title.
        If None, title is disabled.
320
    xlabel : str or None, optional (default="Iterations")
321
322
        X-axis title label.
        If None, title is disabled.
323
    ylabel : str or None, optional (default="@metric@")
324
325
326
        Y-axis title label.
        If 'auto', metric name is used.
        If None, title is disabled.
327
        @metric@ placeholder can be used, and it will be replaced with metric name.
328
329
    figsize : tuple of 2 elements or None, optional (default=None)
        Figure size.
330
331
    dpi : int or None, optional (default=None)
        Resolution of the figure.
332
333
    grid : bool, optional (default=True)
        Whether to add a grid for axes.
334
335
336

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

    if isinstance(booster, LGBMModel):
        eval_results = deepcopy(booster.evals_result_)
    elif isinstance(booster, dict):
        eval_results = deepcopy(booster)
349
    elif isinstance(booster, Booster):
350
351
352
        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`"
        )
353
    else:
354
        raise TypeError("booster must be dict or LGBMModel.")
355
356
357
358

    num_data = len(eval_results)

    if not num_data:
359
        raise ValueError("eval results cannot be empty.")
360
361
362

    if ax is None:
        if figsize is not None:
363
            _check_not_tuple_of_2_elements(figsize, "figsize")
364
        _, ax = plt.subplots(1, 1, figsize=figsize, dpi=dpi)
365
366

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

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

390
    for name in dataset_names_iter:
391
392
        metrics_for_one = eval_results[name]
        results = metrics_for_one[metric]
393
394
        max_result = max(*results, max_result)
        min_result = min(*results, min_result)
395
396
        ax.plot(x_, results, label=name)

397
    ax.legend(loc="best")
398
399

    if xlim is not None:
400
        _check_not_tuple_of_2_elements(xlim, "xlim")
401
402
403
404
405
    else:
        xlim = (0, num_iteration)
    ax.set_xlim(xlim)

    if ylim is not None:
406
        _check_not_tuple_of_2_elements(ylim, "ylim")
407
408
409
410
411
412
413
414
415
416
    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:
417
        ylabel = ylabel.replace("@metric@", metric)
418
419
420
421
422
        ax.set_ylabel(ylabel)
    ax.grid(grid)
    return ax


423
424
425
426
427
428
429
430
431
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
432
433
434
435
    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"
436
    else:
437
        direction = "left" if fval <= threshold else "right"
438
439
440
441
442
    return direction


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


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

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

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

            label += f"<B>{_float2str(threshold, precision)}</B>"
519
            for info in ["split_gain", "internal_value", "internal_weight", "internal_count", "data_percentage"]:
520
                if info in show_info:
521
522
                    output = info.split("_")[-1]
                    if info in {"split_gain", "internal_value", "internal_weight"}:
523
                        label += f"<br/>{_float2str(root[info], precision)} {output}"
524
                    elif info == "internal_count":
525
                        label += f"<br/>{output}: {root[info]}"
526
                    elif info == "data_percentage":
527
                        label += f"<br/>{_float2str(root['internal_count'] / total_count * 100, 2)}% of data"
528
529

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

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

    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")
607
608
609
    return graph


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

622
623
624
625
626
627
628
629
630
631
    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
632
633
634
635
    .. note::

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

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

            - ``'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
651
            - ``'leaf_weight'`` : total weight (sum of Hessian) of all observations that fall into this leaf node
652
            - ``'data_percentage'`` : percentage of training data that fall into this node
653
    precision : int or None, optional (default=3)
654
        Used to restrict the display of floating point values to a certain precision.
655
    orientation : str, optional (default='horizontal')
656
657
        Orientation of the tree.
        Can be 'horizontal' or 'vertical'.
658
659
660
    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.
661
662
663

        .. versionadded:: 4.0.0

664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
    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())

680
681
        .. versionadded:: 4.0.0

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

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

    model = booster.dump_model()
697
698
699
    tree_infos = model["tree_info"]
    feature_names = model.get("feature_names", None)
    monotone_constraints = model.get("monotone_constraints", None)
700

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

    if show_info is None:
        show_info = []

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

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

wxchan's avatar
wxchan committed
735

736
737
738
739
740
741
742
743
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,
744
    orientation: str = "horizontal",
745
    example_case: Optional[Union[np.ndarray, pd_DataFrame]] = None,
746
    **kwargs: Any,
747
) -> Any:
wxchan's avatar
wxchan committed
748
749
    """Plot specified tree.

750
751
752
753
754
755
756
757
758
759
    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
760
761
762
763
    .. 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.
764

wxchan's avatar
wxchan committed
765
766
    Parameters
    ----------
767
768
769
770
771
772
773
774
    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
775
        Figure size.
776
777
    dpi : int or None, optional (default=None)
        Resolution of the figure.
778
    show_info : list of str, or None, optional (default=None)
779
        What information should be shown in nodes.
780
781
782
783
784
785

            - ``'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
786
            - ``'leaf_weight'`` : total weight (sum of Hessian) of all observations that fall into this leaf node
787
            - ``'data_percentage'`` : percentage of training data that fall into this node
788
    precision : int or None, optional (default=3)
789
        Used to restrict the display of floating point values to a certain precision.
790
    orientation : str, optional (default='horizontal')
791
792
        Orientation of the tree.
        Can be 'horizontal' or 'vertical'.
793
794
795
    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.
796
797
798

        .. versionadded:: 4.0.0

799
    **kwargs
800
801
        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
802
803
804

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

    if ax is None:
815
        if figsize is not None:
816
            _check_not_tuple_of_2_elements(figsize, "figsize")
817
        _, ax = plt.subplots(1, 1, figsize=figsize, dpi=dpi)
wxchan's avatar
wxchan committed
818

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

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

    ax.imshow(img)
835
    ax.axis("off")
wxchan's avatar
wxchan committed
836
    return ax