Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

add lightgbm.booster support #270

Open
wants to merge 22 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ It provides support for the following machine learning frameworks and packages:
XGBRegressor and xgboost.Booster.

* LightGBM_ - show feature importances and explain predictions of
LGBMClassifier and LGBMRegressor.
LGBMClassifier, LGBMRegressor and lightgbm.Booster.

* lightning_ - explain weights and predictions of lightning classifiers and
regressors.
Expand Down
17 changes: 12 additions & 5 deletions docs/source/libraries/lightgbm.rst
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,12 @@ LightGBM

LightGBM_ is a fast Gradient Boosting framework; it provides a Python
interface. eli5 supports :func:`eli5.explain_weights`
and :func:`eli5.explain_prediction` for ``lightgbm.LGBMClassifer``
and ``lightgbm.LGBMRegressor`` estimators.
and :func:`eli5.explain_prediction` for ``lightgbm.LGBMClassifer``, ``lightgbm.LGBMRegressor`` and ``lightgbm.Booster`` estimators.

.. _LightGBM: https://github.com/Microsoft/LightGBM

:func:`eli5.explain_weights` uses feature importances. Additional
arguments for LGBMClassifier and LGBMClassifier:
arguments for LGBMClassifier , LGBMClassifier and lightgbm.Booster:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

a nitpick: extra whitespace before comma

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
arguments for LGBMClassifier , LGBMClassifier and lightgbm.Booster:
arguments for LGBMClassifier, LGBMClassifier and lightgbm.Booster:


* ``importance_type`` is a way to get feature importance. Possible values are:

Expand All @@ -22,7 +21,7 @@ arguments for LGBMClassifier and LGBMClassifier:
- 'weight' - the same as 'split', for better compatibility with
:ref:`library-xgboost`.

``target_names`` and ``target`` arguments are ignored.
``target_names`` arguement is ignored for ``lightgbm.LGBMClassifer`` / ``lightgbm.LGBMRegressor``, but used for ``lightgbm.Booster``. ``target`` argument is ignored.
Copy link
Contributor

@kmike kmike Nov 18, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
``target_names`` arguement is ignored for ``lightgbm.LGBMClassifer`` / ``lightgbm.LGBMRegressor``, but used for ``lightgbm.Booster``. ``target`` argument is ignored.
``target_names`` argument is ignored for ``lightgbm.LGBMClassifer`` / ``lightgbm.LGBMRegressor``, but used for ``lightgbm.Booster``. ``targets`` argument is ignored.


.. note::
Top-level :func:`eli5.explain_weights` calls are dispatched
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think lightgbm.Booster should be mentioned here as well.

Expand All @@ -37,7 +36,7 @@ contribution of a feature on the decision path is how much the score changes
from parent to child.

Additional :func:`eli5.explain_prediction` keyword arguments supported
for ``lightgbm.LGBMClassifer`` and ``lightgbm.LGBMRegressor``:
for ``lightgbm.LGBMClassifer``, ``lightgbm.LGBMRegressor`` and ``lightgbm.Booster``:

* ``vec`` is a vectorizer instance used to transform
raw features to the input of the estimator ``lgb``
Expand All @@ -50,6 +49,14 @@ for ``lightgbm.LGBMClassifer`` and ``lightgbm.LGBMRegressor``:
estimator. Set it to True if you're passing ``vec``,
but ``doc`` is already vectorized.

``lightgbm.Booster`` estimator accepts one more optional argument:

* ``is_regression`` - True if solving a regression problem
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this parameter supported? It is not an argument of explain_prediction_lightgbm.

("objective" starts with "reg")
and False for a classification problem.
If not set, regression is assumed for a single target estimator
and proba will not be shown unless the ``target_names`` is defined as a list with length of two.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems to contradict a note above (line 25, "target_names is ignored") - does the note about target_names apply only to classifier/regressor, but not for Booster?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

for a single target estimater, booster cannot recognize wheather it is a regression problem or not. We assume it is regression in default, unless users set it as a classification problem by assigning 'target names' input [0,1] etc. Only in this case 'target names' is used. Should I remove " target names is ignored" to prevent confusion?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

target names is still ignored for classifier/regressor, right? I think it makes sense to clarify it - just say that it is ignored for LGBMClassifier / LGBMRegressor, but used for lightgbm.Booster.

"is defined as a list with length of two" - sohuld it be 2 elements, or 2+ is also supported?

Copy link
Author

@qh582 qh582 May 31, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well, for single target booster, only 1+ elements target_name point booster to classification. Otherwise, this booster is regression . there is risk user assign wrong number of elements like 3, but not sure how eli5 will raise error. 2+ should not support here.


.. note::
Top-level :func:`eli5.explain_prediction` calls are dispatched
to :func:`eli5.xgboost.explain_prediction_lightgbm` for
Expand Down
2 changes: 1 addition & 1 deletion docs/source/overview.rst
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ following machine learning frameworks and packages:
of XGBClassifier, XGBRegressor and xgboost.Booster.

* :ref:`library-lightgbm` - show feature importances and explain predictions
of LGBMClassifier and LGBMRegressor.
of LGBMClassifier , LGBMRegressor and lightgbm.Booster.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
of LGBMClassifier , LGBMRegressor and lightgbm.Booster.
of LGBMClassifier, LGBMRegressor and lightgbm.Booster.


* :ref:`library-lightning` - explain weights and predictions of lightning
classifiers and regressors.
Expand Down
98 changes: 72 additions & 26 deletions eli5/lightgbm.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# -*- coding: utf-8 -*-
from __future__ import absolute_import, division
from collections import defaultdict
from typing import DefaultDict
from typing import DefaultDict, Any, Tuple

import numpy as np # type: ignore
import lightgbm # type: ignore
Expand All @@ -17,7 +17,7 @@
all values sum to 1.
"""


@explain_weights.register(lightgbm.Booster)
@explain_weights.register(lightgbm.LGBMClassifier)
@explain_weights.register(lightgbm.LGBMRegressor)
def explain_weights_lightgbm(lgb,
Expand All @@ -32,13 +32,15 @@ def explain_weights_lightgbm(lgb,
):
"""
Return an explanation of an LightGBM estimator (via scikit-learn wrapper
LGBMClassifier or LGBMRegressor) as feature importances.
LGBMClassifier or LGBMRegressor, or via lightgbm.Booster) as feature importances.

See :func:`eli5.explain_weights` for description of
``top``, ``feature_names``,
``feature_re`` and ``feature_filter`` parameters.

``target_names`` and ``targets`` parameters are ignored.
``target_names`` arguement is ignored for ``lightgbm.LGBMClassifer`` / ``lightgbm.LGBMRegressor``,
but used for ``lightgbm.Booster``.
``target`` argument is ignored.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
``target`` argument is ignored.
``targets`` argument is ignored.


Parameters
----------
Expand All @@ -51,8 +53,9 @@ def explain_weights_lightgbm(lgb,
across all trees
- 'weight' - the same as 'split', for compatibility with xgboost
"""
coef = _get_lgb_feature_importances(lgb, importance_type)
lgb_feature_names = lgb.booster_.feature_name()
booster, is_regression = _check_booster_args(lgb)
coef = _get_lgb_feature_importances(booster, importance_type)
lgb_feature_names = booster.feature_name()
return get_feature_importance_explanation(lgb, vec, coef,
feature_names=feature_names,
estimator_feature_names=lgb_feature_names,
Expand All @@ -64,7 +67,7 @@ def explain_weights_lightgbm(lgb,
is_regression=isinstance(lgb, lightgbm.LGBMRegressor),
)


@explain_prediction.register(lightgbm.Booster)
@explain_prediction.register(lightgbm.LGBMClassifier)
@explain_prediction.register(lightgbm.LGBMRegressor)
def explain_prediction_lightgbm(
Expand All @@ -80,7 +83,7 @@ def explain_prediction_lightgbm(
vectorized=False,
):
""" Return an explanation of LightGBM prediction (via scikit-learn wrapper
LGBMClassifier or LGBMRegressor) as feature weights.
LGBMClassifier or LGBMRegressor, or via lightgbm.Booster) as feature weights.

See :func:`eli5.explain_prediction` for description of
``top``, ``top_targets``, ``target_names``, ``targets``,
Expand Down Expand Up @@ -108,20 +111,49 @@ def explain_prediction_lightgbm(
Weights of all features sum to the output score of the estimator.
"""

vec, feature_names = handle_vec(lgb, doc, vec, vectorized, feature_names)
booster, is_regression = _check_booster_args(lgb)
lgb_feature_names = booster.feature_name()
vec, feature_names = handle_vec(lgb, doc, vec, vectorized, feature_names,
num_features=len(lgb_feature_names))
if feature_names.bias_name is None:
# LightGBM estimators do not have an intercept, but here we interpret
# them as having an intercept
feature_names.bias_name = '<BIAS>'
X = get_X(doc, vec, vectorized=vectorized)

if isinstance(lgb, lightgbm.Booster):
prediction = lgb.predict(X)
n_targets = prediction.shape[-1]
if is_regression is None and target_names is None:
# When n_targets is 1, this can be classification too.
# It's safer to assume regression in this case,
# unless users set it as a classification problem by assigning 'target_names' input [0,1] etc.
# If n_targets > 1, it must be classification.
is_regression = n_targets == 1
elif is_regression is None:
is_regression = len(target_names) == 1 and n_targets == 1

if is_regression:
proba = None
else:
if n_targets == 1:
p, = prediction
proba = np.array([1 - p, p])
else:
proba, = prediction
else:
proba = predict_proba(lgb, X)
n_targets = _lgb_n_targets(lgb)

proba = predict_proba(lgb, X)
weight_dicts = _get_prediction_feature_weights(lgb, X, _lgb_n_targets(lgb))
x = get_X0(add_intercept(X))
if is_regression:
names = ['y']
elif isinstance(lgb, lightgbm.Booster):
names = np.arange(max(2, n_targets))
else:
names = lgb.classes_

is_regression = isinstance(lgb, lightgbm.LGBMRegressor)
is_multiclass = _lgb_n_targets(lgb) > 2
names = lgb.classes_ if not is_regression else ['y']
weight_dicts = _get_prediction_feature_weights(booster, X, n_targets)
x = get_X0(add_intercept(X))

def get_score_weights(_label_id):
_weights = _target_feature_weights(
Expand All @@ -145,22 +177,38 @@ def get_score_weights(_label_id):
targets=targets,
top_targets=top_targets,
is_regression=is_regression,
is_multiclass=is_multiclass,
is_multiclass=n_targets > 1,
proba=proba,
get_score_weights=get_score_weights,
)


def _check_booster_args(lgb, is_regression=None):
# type: (Any, bool) -> Tuple[lightgbm.Booster, bool]
if isinstance(lgb, lightgbm.Booster):
booster = lgb
else:
booster = lgb.booster_
_is_regression = isinstance(lgb, lightgbm.LGBMRegressor)
if is_regression is not None and is_regression != _is_regression:
raise ValueError(
'Inconsistent is_regression={} passed. '
'You don\'t have to pass it when using scikit-learn API'
.format(is_regression))
is_regression = _is_regression
return booster, is_regression

def _lgb_n_targets(lgb):
if isinstance(lgb, lightgbm.LGBMClassifier):
return lgb.n_classes_
else:
return 1 if lgb.n_classes_ == 2 else lgb.n_classes_
elif isinstance(lgb, lightgbm.LGBMRegressor):
return 1
else:
raise TypeError


def _get_lgb_feature_importances(lgb, importance_type):
def _get_lgb_feature_importances(booster, importance_type):
aliases = {'weight': 'split'}
coef = lgb.booster_.feature_importance(
coef = booster.feature_importance(
importance_type=aliases.get(importance_type, importance_type)
)
norm = coef.sum()
Expand Down Expand Up @@ -237,17 +285,15 @@ def walk(tree, parent_id=-1):
return leaf_index, split_index


def _get_prediction_feature_weights(lgb, X, n_targets):
def _get_prediction_feature_weights(booster, X, n_targets):
"""
Return a list of {feat_id: value} dicts with feature weights,
following ideas from http://blog.datadive.net/interpreting-random-forests/
"""
if n_targets == 2:
n_targets = 1
dump = lgb.booster_.dump_model()
dump = booster.dump_model()
tree_info = dump['tree_info']
_compute_node_values(tree_info)
pred_leafs = lgb.booster_.predict(X, pred_leaf=True).reshape(-1, n_targets)
pred_leafs = booster.predict(X, pred_leaf=True).reshape(-1, n_targets)
tree_info = np.array(tree_info).reshape(-1, n_targets)
assert pred_leafs.shape == tree_info.shape

Expand Down
Loading