test_plotting.py 19.9 KB
Newer Older
1
# coding: utf-8
2
import numpy as np
3
import pandas as pd
4
import pytest
5
6
7
from sklearn.model_selection import train_test_split

import lightgbm as lgb
8
from lightgbm.compat import GRAPHVIZ_INSTALLED, MATPLOTLIB_INSTALLED, PANDAS_INSTALLED, pd_DataFrame
9

10
if MATPLOTLIB_INSTALLED:
wxchan's avatar
wxchan committed
11
12
    import matplotlib
    matplotlib.use('Agg')
13
14
if GRAPHVIZ_INSTALLED:
    import graphviz
15

16
from .utils import load_breast_cancer, make_synthetic_regression
17

18

19
20
21
22
23
24
@pytest.fixture(scope="module")
def breast_cancer_split():
    return train_test_split(*load_breast_cancer(return_X_y=True),
                            test_size=0.1, random_state=1)


25
26
27
28
29
30
31
32
33
34
35
def _categorical_data(category_values_lower_bound, category_values_upper_bound):
    X, y = load_breast_cancer(return_X_y=True)
    X_df = pd.DataFrame()
    rnd = np.random.RandomState(0)
    n_cat_values = rnd.randint(category_values_lower_bound, category_values_upper_bound, size=X.shape[1])
    for i in range(X.shape[1]):
        bins = np.linspace(0, 1, num=n_cat_values[i] + 1)
        X_df[f"cat_col_{i}"] = pd.qcut(X[:, i], q=bins, labels=range(n_cat_values[i])).as_unordered()
    return X_df, y


36
37
38
39
40
@pytest.fixture(scope="module")
def train_data(breast_cancer_split):
    X_train, _, y_train, _ = breast_cancer_split
    return lgb.Dataset(X_train, y_train)

41

42
43
44
@pytest.fixture
def params():
    return {"objective": "binary",
45
            "verbose": -1,
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
            "num_leaves": 3}


@pytest.mark.skipif(not MATPLOTLIB_INSTALLED, reason='matplotlib is not installed')
def test_plot_importance(params, breast_cancer_split, train_data):
    X_train, _, y_train, _ = breast_cancer_split

    gbm0 = lgb.train(params, train_data, num_boost_round=10)
    ax0 = lgb.plot_importance(gbm0)
    assert isinstance(ax0, matplotlib.axes.Axes)
    assert ax0.get_title() == 'Feature importance'
    assert ax0.get_xlabel() == 'Feature importance'
    assert ax0.get_ylabel() == 'Features'
    assert len(ax0.patches) <= 30

61
    gbm1 = lgb.LGBMClassifier(n_estimators=10, num_leaves=3, verbose=-1)
62
63
64
65
66
67
68
69
70
71
72
    gbm1.fit(X_train, y_train)

    ax1 = lgb.plot_importance(gbm1, color='r', title='t', xlabel='x', ylabel='y')
    assert isinstance(ax1, matplotlib.axes.Axes)
    assert ax1.get_title() == 't'
    assert ax1.get_xlabel() == 'x'
    assert ax1.get_ylabel() == 'y'
    assert len(ax1.patches) <= 30
    for patch in ax1.patches:
        assert patch.get_facecolor() == (1., 0, 0, 1.)  # red

73
    ax2 = lgb.plot_importance(gbm0, color=['r', 'y', 'g', 'b'], title=None, xlabel=None, ylabel=None)
74
75
76
77
78
79
80
81
82
83
    assert isinstance(ax2, matplotlib.axes.Axes)
    assert ax2.get_title() == ''
    assert ax2.get_xlabel() == ''
    assert ax2.get_ylabel() == ''
    assert len(ax2.patches) <= 30
    assert ax2.patches[0].get_facecolor() == (1., 0, 0, 1.)  # r
    assert ax2.patches[1].get_facecolor() == (.75, .75, 0, 1.)  # y
    assert ax2.patches[2].get_facecolor() == (0, .5, 0, 1.)  # g
    assert ax2.patches[3].get_facecolor() == (0, 0, 1., 1.)  # b

84
85
86
87
88
89
90
    ax3 = lgb.plot_importance(gbm0, title='t @importance_type@', xlabel='x @importance_type@', ylabel='y @importance_type@')
    assert isinstance(ax3, matplotlib.axes.Axes)
    assert ax3.get_title() == 't @importance_type@'
    assert ax3.get_xlabel() == 'x split'
    assert ax3.get_ylabel() == 'y @importance_type@'
    assert len(ax3.patches) <= 30

91
    gbm2 = lgb.LGBMClassifier(n_estimators=10, num_leaves=3, verbose=-1, importance_type="gain")
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
    gbm2.fit(X_train, y_train)

    def get_bounds_of_first_patch(axes):
        return axes.patches[0].get_extents().bounds

    first_bar1 = get_bounds_of_first_patch(lgb.plot_importance(gbm1))
    first_bar2 = get_bounds_of_first_patch(lgb.plot_importance(gbm1, importance_type="split"))
    first_bar3 = get_bounds_of_first_patch(lgb.plot_importance(gbm1, importance_type="gain"))
    first_bar4 = get_bounds_of_first_patch(lgb.plot_importance(gbm2))
    first_bar5 = get_bounds_of_first_patch(lgb.plot_importance(gbm2, importance_type="split"))
    first_bar6 = get_bounds_of_first_patch(lgb.plot_importance(gbm2, importance_type="gain"))

    assert first_bar1 == first_bar2
    assert first_bar1 == first_bar5
    assert first_bar3 == first_bar4
    assert first_bar3 == first_bar6
    assert first_bar1 != first_bar3

110
111
112
113
114
115
116
117
118
119
120
121
122

@pytest.mark.skipif(not MATPLOTLIB_INSTALLED, reason='matplotlib is not installed')
def test_plot_split_value_histogram(params, breast_cancer_split, train_data):
    X_train, _, y_train, _ = breast_cancer_split

    gbm0 = lgb.train(params, train_data, num_boost_round=10)
    ax0 = lgb.plot_split_value_histogram(gbm0, 27)
    assert isinstance(ax0, matplotlib.axes.Axes)
    assert ax0.get_title() == 'Split value histogram for feature with index 27'
    assert ax0.get_xlabel() == 'Feature split value'
    assert ax0.get_ylabel() == 'Count'
    assert len(ax0.patches) <= 2

123
    gbm1 = lgb.LGBMClassifier(n_estimators=10, num_leaves=3, verbose=-1)
124
125
126
127
128
129
    gbm1.fit(X_train, y_train)

    ax1 = lgb.plot_split_value_histogram(gbm1, gbm1.booster_.feature_name()[27], figsize=(10, 5),
                                         title='Histogram for feature @index/name@ @feature@',
                                         xlabel='x', ylabel='y', color='r')
    assert isinstance(ax1, matplotlib.axes.Axes)
130
    title = f'Histogram for feature name {gbm1.booster_.feature_name()[27]}'
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
    assert ax1.get_title() == title
    assert ax1.get_xlabel() == 'x'
    assert ax1.get_ylabel() == 'y'
    assert len(ax1.patches) <= 2
    for patch in ax1.patches:
        assert patch.get_facecolor() == (1., 0, 0, 1.)  # red

    ax2 = lgb.plot_split_value_histogram(gbm0, 27, bins=10, color=['r', 'y', 'g', 'b'],
                                         title=None, xlabel=None, ylabel=None)
    assert isinstance(ax2, matplotlib.axes.Axes)
    assert ax2.get_title() == ''
    assert ax2.get_xlabel() == ''
    assert ax2.get_ylabel() == ''
    assert len(ax2.patches) == 10
    assert ax2.patches[0].get_facecolor() == (1., 0, 0, 1.)  # r
    assert ax2.patches[1].get_facecolor() == (.75, .75, 0, 1.)  # y
    assert ax2.patches[2].get_facecolor() == (0, .5, 0, 1.)  # g
    assert ax2.patches[3].get_facecolor() == (0, 0, 1., 1.)  # b

    with pytest.raises(ValueError):
        lgb.plot_split_value_histogram(gbm0, 0)  # was not used in splitting


@pytest.mark.skipif(not MATPLOTLIB_INSTALLED or not GRAPHVIZ_INSTALLED,
                    reason='matplotlib or graphviz is not installed')
def test_plot_tree(breast_cancer_split):
    X_train, _, y_train, _ = breast_cancer_split
158
    gbm = lgb.LGBMClassifier(n_estimators=10, num_leaves=3, verbose=-1)
159
    gbm.fit(X_train, y_train)
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175

    with pytest.raises(IndexError):
        lgb.plot_tree(gbm, tree_index=83)

    ax = lgb.plot_tree(gbm, tree_index=3, figsize=(15, 8), show_info=['split_gain'])
    assert isinstance(ax, matplotlib.axes.Axes)
    w, h = ax.axes.get_figure().get_size_inches()
    assert int(w) == 15
    assert int(h) == 8


@pytest.mark.skipif(not GRAPHVIZ_INSTALLED, reason='graphviz is not installed')
def test_create_tree_digraph(breast_cancer_split):
    X_train, _, y_train, _ = breast_cancer_split

    constraints = [-1, 1] * int(X_train.shape[1] / 2)
176
    gbm = lgb.LGBMClassifier(n_estimators=10, num_leaves=3, verbose=-1, monotone_constraints=constraints)
177
    gbm.fit(X_train, y_train)
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202

    with pytest.raises(IndexError):
        lgb.create_tree_digraph(gbm, tree_index=83)

    graph = lgb.create_tree_digraph(gbm, tree_index=3,
                                    show_info=['split_gain', 'internal_value', 'internal_weight'],
                                    name='Tree4', node_attr={'color': 'red'})
    graph.render(view=False)
    assert isinstance(graph, graphviz.Digraph)
    assert graph.name == 'Tree4'
    assert len(graph.node_attr) == 1
    assert graph.node_attr['color'] == 'red'
    assert len(graph.graph_attr) == 0
    assert len(graph.edge_attr) == 0
    graph_body = ''.join(graph.body)
    assert 'leaf' in graph_body
    assert 'gain' in graph_body
    assert 'value' in graph_body
    assert 'weight' in graph_body
    assert '#ffdddd' in graph_body
    assert '#ddffdd' in graph_body
    assert 'data' not in graph_body
    assert 'count' not in graph_body


203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
@pytest.mark.skipif(not GRAPHVIZ_INSTALLED, reason='graphviz is not installed')
def test_tree_with_categories_below_max_category_values():
    X_train, y_train = _categorical_data(2, 10)
    params = {
        "n_estimators": 10,
        "num_leaves": 3,
        "min_data_in_bin": 1,
        "force_col_wise": True,
        "deterministic": True,
        "num_threads": 1,
        "seed": 708,
        "verbose": -1
    }
    gbm = lgb.LGBMClassifier(**params)
    gbm.fit(X_train, y_train)

    with pytest.raises(IndexError):
        lgb.create_tree_digraph(gbm, tree_index=83)

    graph = lgb.create_tree_digraph(gbm, tree_index=3,
                                    show_info=['split_gain', 'internal_value', 'internal_weight'],
                                    name='Tree4', node_attr={'color': 'red'},
                                    max_category_values=10)
    graph.render(view=False)
    assert isinstance(graph, graphviz.Digraph)
    assert graph.name == 'Tree4'
    assert len(graph.node_attr) == 1
    assert graph.node_attr['color'] == 'red'
    assert len(graph.graph_attr) == 0
    assert len(graph.edge_attr) == 0
    graph_body = ''.join(graph.body)
    assert 'leaf' in graph_body
    assert 'gain' in graph_body
    assert 'value' in graph_body
    assert 'weight' in graph_body
    assert 'data' not in graph_body
    assert 'count' not in graph_body
    assert '||...||' not in graph_body


@pytest.mark.skipif(not GRAPHVIZ_INSTALLED, reason='graphviz is not installed')
def test_tree_with_categories_above_max_category_values():
    X_train, y_train = _categorical_data(20, 30)
    params = {
        "n_estimators": 10,
        "num_leaves": 3,
        "min_data_in_bin": 1,
        "force_col_wise": True,
        "deterministic": True,
        "num_threads": 1,
        "seed": 708,
        "verbose": -1
    }
    gbm = lgb.LGBMClassifier(**params)
    gbm.fit(X_train, y_train)

    with pytest.raises(IndexError):
        lgb.create_tree_digraph(gbm, tree_index=83)

    graph = lgb.create_tree_digraph(gbm, tree_index=9,
                                    show_info=['split_gain', 'internal_value', 'internal_weight'],
                                    name='Tree4', node_attr={'color': 'red'},
                                    max_category_values=4)
    graph.render(view=False)
    assert isinstance(graph, graphviz.Digraph)
    assert graph.name == 'Tree4'
    assert len(graph.node_attr) == 1
    assert graph.node_attr['color'] == 'red'
    assert len(graph.graph_attr) == 0
    assert len(graph.edge_attr) == 0
    graph_body = ''.join(graph.body)
    assert 'leaf' in graph_body
    assert 'gain' in graph_body
    assert 'value' in graph_body
    assert 'weight' in graph_body
    assert 'data' not in graph_body
    assert 'count' not in graph_body
    assert '||...||' in graph_body


283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
@pytest.mark.parametrize('use_missing', [True, False])
@pytest.mark.parametrize('zero_as_missing', [True, False])
def test_numeric_split_direction(use_missing, zero_as_missing):
    if use_missing and zero_as_missing:
        pytest.skip('use_missing and zero_as_missing both set to True')
    X, y = make_synthetic_regression()
    rng = np.random.RandomState(0)
    zero_mask = rng.rand(X.shape[0]) < 0.05
    X[zero_mask, :] = 0
    if use_missing:
        nan_mask = ~zero_mask & (rng.rand(X.shape[0]) < 0.1)
        X[nan_mask, :] = np.nan
    ds = lgb.Dataset(X, y)
    params = {
        'num_leaves': 127,
        'min_child_samples': 1,
        'use_missing': use_missing,
        'zero_as_missing': zero_as_missing,
    }
    bst = lgb.train(params, ds, num_boost_round=1)

    case_with_zero = X[zero_mask][[0]]
    expected_leaf_zero = bst.predict(case_with_zero, pred_leaf=True)[0]
    node = bst.dump_model()['tree_info'][0]['tree_structure']
    while 'decision_type' in node:
        direction = lgb.plotting._determine_direction_for_numeric_split(
            case_with_zero[0][node['split_feature']], node['threshold'], node['missing_type'], node['default_left']
        )
        node = node['left_child'] if direction == 'left' else node['right_child']
    assert node['leaf_index'] == expected_leaf_zero

    if use_missing:
        case_with_nan = X[nan_mask][[0]]
        expected_leaf_nan = bst.predict(case_with_nan, pred_leaf=True)[0]
        node = bst.dump_model()['tree_info'][0]['tree_structure']
        while 'decision_type' in node:
            direction = lgb.plotting._determine_direction_for_numeric_split(
                case_with_nan[0][node['split_feature']], node['threshold'], node['missing_type'], node['default_left']
            )
            node = node['left_child'] if direction == 'left' else node['right_child']
        assert node['leaf_index'] == expected_leaf_nan
        assert expected_leaf_zero != expected_leaf_nan


@pytest.mark.skipif(not GRAPHVIZ_INSTALLED, reason='graphviz is not installed')
def test_example_case_in_tree_digraph():
    rng = np.random.RandomState(0)
    x1 = rng.rand(100)
    cat = rng.randint(1, 3, size=x1.size)
    X = np.vstack([x1, cat]).T
    y = x1 + 2 * cat
    feature_name = ['x1', 'cat']
    ds = lgb.Dataset(X, y, feature_name=feature_name, categorical_feature=['cat'])

    num_round = 3
    bst = lgb.train({'num_leaves': 7}, ds, num_boost_round=num_round)
    mod = bst.dump_model()
    example_case = X[[0]]
    makes_categorical_splits = False
    seen_indices = set()
    for i in range(num_round):
        graph = lgb.create_tree_digraph(bst, example_case=example_case, tree_index=i)
        gbody = graph.body
        node = mod['tree_info'][i]['tree_structure']
        while 'decision_type' in node:  # iterate through the splits
            split_index = node['split_index']

            node_in_graph = [n for n in gbody if f'split{split_index}' in n and '->' not in n]
            assert len(node_in_graph) == 1
            seen_indices.add(gbody.index(node_in_graph[0]))

            edge_to_node = [e for e in gbody if f'-> split{split_index}' in e]
            if node['decision_type'] == '<=':
                direction = lgb.plotting._determine_direction_for_numeric_split(
                    example_case[0][node['split_feature']], node['threshold'], node['missing_type'], node['default_left'])
            else:
                makes_categorical_splits = True
                direction = lgb.plotting._determine_direction_for_categorical_split(
                    example_case[0][node['split_feature']], node['threshold']
                )
            node = node['left_child'] if direction == 'left' else node['right_child']
            assert 'color=blue' in node_in_graph[0]
            if edge_to_node:
                assert len(edge_to_node) == 1
                assert 'color=blue' in edge_to_node[0]
                seen_indices.add(gbody.index(edge_to_node[0]))
        # we're in a leaf now
        leaf_index = node['leaf_index']
        leaf_in_graph = [n for n in gbody if f'leaf{leaf_index}' in n and '->' not in n]
        edge_to_leaf = [e for e in gbody if f'-> leaf{leaf_index}' in e]
        assert len(leaf_in_graph) == 1
        assert 'color=blue' in leaf_in_graph[0]
        assert len(edge_to_leaf) == 1
        assert 'color=blue' in edge_to_leaf[0]
        seen_indices.update([gbody.index(leaf_in_graph[0]), gbody.index(edge_to_leaf[0])])

        # check that the rest of the elements have black color
        remaining_elements = [e for i, e in enumerate(graph.body) if i not in seen_indices and 'graph' not in e]
        assert all('color=black' in e for e in remaining_elements)

        # check that we got to the expected leaf
        expected_leaf = bst.predict(example_case, start_iteration=i, num_iteration=1, pred_leaf=True)[0]
        assert leaf_index == expected_leaf
    assert makes_categorical_splits


@pytest.mark.skipif(not GRAPHVIZ_INSTALLED, reason='graphviz is not installed')
@pytest.mark.parametrize('input_type', ['array', 'dataframe'])
def test_empty_example_case_on_tree_digraph_raises_error(input_type):
    X, y = make_synthetic_regression()
    if input_type == 'dataframe':
        if not PANDAS_INSTALLED:
            pytest.skip(reason='pandas is not installed')
        X = pd_DataFrame(X)
    ds = lgb.Dataset(X, y)
    bst = lgb.train({'num_leaves': 3}, ds, num_boost_round=1)
    example_case = X[:0]
    if input_type == 'dataframe':
        example_case = pd_DataFrame(example_case)
    with pytest.raises(ValueError, match='example_case must have a single row.'):
        lgb.create_tree_digraph(bst, tree_index=0, example_case=example_case)


406
407
408
409
410
411
412
413
414
415
416
@pytest.mark.skipif(not MATPLOTLIB_INSTALLED, reason='matplotlib is not installed')
def test_plot_metrics(params, breast_cancer_split, train_data):
    X_train, X_test, y_train, y_test = breast_cancer_split
    test_data = lgb.Dataset(X_test, y_test, reference=train_data)
    params.update({"metric": {"binary_logloss", "binary_error"}})

    evals_result0 = {}
    lgb.train(params, train_data,
              valid_sets=[train_data, test_data],
              valid_names=['v1', 'v2'],
              num_boost_round=10,
417
              callbacks=[lgb.record_evaluation(evals_result0)])
418
419
    with pytest.warns(UserWarning, match="More than one metric available, picking one to plot."):
        ax0 = lgb.plot_metric(evals_result0)
420
421
422
423
    assert isinstance(ax0, matplotlib.axes.Axes)
    assert ax0.get_title() == 'Metric during training'
    assert ax0.get_xlabel() == 'Iterations'
    assert ax0.get_ylabel() in {'binary_logloss', 'binary_error'}
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
    legend_items = ax0.get_legend().get_texts()
    assert len(legend_items) == 2
    assert legend_items[0].get_text() == 'v1'
    assert legend_items[1].get_text() == 'v2'

    ax1 = lgb.plot_metric(evals_result0, metric='binary_error')
    assert isinstance(ax1, matplotlib.axes.Axes)
    assert ax1.get_title() == 'Metric during training'
    assert ax1.get_xlabel() == 'Iterations'
    assert ax1.get_ylabel() == 'binary_error'
    legend_items = ax1.get_legend().get_texts()
    assert len(legend_items) == 2
    assert legend_items[0].get_text() == 'v1'
    assert legend_items[1].get_text() == 'v2'

    ax2 = lgb.plot_metric(evals_result0, metric='binary_logloss', dataset_names=['v2'])
    assert isinstance(ax2, matplotlib.axes.Axes)
    assert ax2.get_title() == 'Metric during training'
    assert ax2.get_xlabel() == 'Iterations'
    assert ax2.get_ylabel() == 'binary_logloss'
    legend_items = ax2.get_legend().get_texts()
    assert len(legend_items) == 1
    assert legend_items[0].get_text() == 'v2'

    ax3 = lgb.plot_metric(
        evals_result0,
        metric='binary_logloss',
        dataset_names=['v1'],
        title='Metric @metric@',
        xlabel='Iterations @metric@',
        ylabel='Value of "@metric@"',
        figsize=(5, 5),
        dpi=600,
        grid=False
    )
    assert isinstance(ax3, matplotlib.axes.Axes)
    assert ax3.get_title() == 'Metric @metric@'
    assert ax3.get_xlabel() == 'Iterations @metric@'
    assert ax3.get_ylabel() == 'Value of "binary_logloss"'
    legend_items = ax3.get_legend().get_texts()
    assert len(legend_items) == 1
    assert legend_items[0].get_text() == 'v1'
    assert ax3.get_figure().get_figheight() == 5
    assert ax3.get_figure().get_figwidth() == 5
    assert ax3.get_figure().get_dpi() == 600
    for grid_line in ax3.get_xgridlines():
        assert not grid_line.get_visible()
    for grid_line in ax3.get_ygridlines():
        assert not grid_line.get_visible()
473
474
475
476

    evals_result1 = {}
    lgb.train(params, train_data,
              num_boost_round=10,
477
              callbacks=[lgb.record_evaluation(evals_result1)])
478
    with pytest.raises(ValueError, match="eval results cannot be empty."):
479
480
        lgb.plot_metric(evals_result1)

481
    gbm2 = lgb.LGBMClassifier(n_estimators=10, num_leaves=3, verbose=-1)
482
    gbm2.fit(X_train, y_train, eval_set=[(X_test, y_test)])
483
484
485
486
487
488
489
490
    ax4 = lgb.plot_metric(gbm2, title=None, xlabel=None, ylabel=None)
    assert isinstance(ax4, matplotlib.axes.Axes)
    assert ax4.get_title() == ''
    assert ax4.get_xlabel() == ''
    assert ax4.get_ylabel() == ''
    legend_items = ax4.get_legend().get_texts()
    assert len(legend_items) == 1
    assert legend_items[0].get_text() == 'valid_0'