Skip to content

Commit

Permalink
[python-package] allow custom weighing in fobj for scikit-learn API (c…
Browse files Browse the repository at this point in the history
…loses #5027) (#5211)

* allow custom weighing in sklearn api

* add suggestions from review

Co-authored-by: Nikita Titov <[email protected]>
  • Loading branch information
jmoralez and StrikerRUS authored Jun 27, 2022
1 parent e906a82 commit b6deb9a
Show file tree
Hide file tree
Showing 4 changed files with 89 additions and 24 deletions.
34 changes: 19 additions & 15 deletions python-package/lightgbm/sklearn.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@
[np.ndarray, np.ndarray, np.ndarray],
Tuple[np.ndarray, np.ndarray]
],
Callable[
[np.ndarray, np.ndarray, np.ndarray, np.ndarray],
Tuple[np.ndarray, np.ndarray]
],
]
_LGBM_ScikitCustomEvalFunction = Union[
Callable[
Expand Down Expand Up @@ -54,7 +58,10 @@ def __init__(self, func: _LGBM_ScikitCustomObjectiveFunction):
Parameters
----------
func : callable
Expects a callable with signature ``func(y_true, y_pred)`` or ``func(y_true, y_pred, group)``
Expects a callable with following signatures:
``func(y_true, y_pred)``,
``func(y_true, y_pred, weight)``
or ``func(y_true, y_pred, weight, group)``
and returns (grad, hess):
y_true : numpy 1-D array of shape = [n_samples]
Expand All @@ -63,6 +70,8 @@ def __init__(self, func: _LGBM_ScikitCustomObjectiveFunction):
The predicted values.
Predicted values are returned before any transformation,
e.g. they are raw margin instead of probability of positive class for binary task.
weight : numpy 1-D array of shape = [n_samples]
The weight of samples. Weights should be non-negative.
group : numpy 1-D array
Group/query data.
Only used in the learning-to-rank task.
Expand Down Expand Up @@ -107,19 +116,11 @@ def __call__(self, preds: np.ndarray, dataset: Dataset) -> Tuple[np.ndarray, np.
if argc == 2:
grad, hess = self.func(labels, preds)
elif argc == 3:
grad, hess = self.func(labels, preds, dataset.get_group())
grad, hess = self.func(labels, preds, dataset.get_weight())
elif argc == 4:
grad, hess = self.func(labels, preds, dataset.get_weight(), dataset.get_group())
else:
raise TypeError(f"Self-defined objective function should have 2 or 3 arguments, got {argc}")
"""weighted for objective"""
weight = dataset.get_weight()
if weight is not None:
if grad.ndim == 2: # multi-class
num_data = grad.shape[0]
if weight.size != num_data:
raise ValueError("grad and hess should be of shape [n_samples, n_classes]")
weight = weight.reshape(num_data, 1)
grad *= weight
hess *= weight
raise TypeError(f"Self-defined objective function should have 2, 3 or 4 arguments, got {argc}")
return grad, hess


Expand Down Expand Up @@ -456,15 +457,18 @@ def __init__(
----
A custom objective function can be provided for the ``objective`` parameter.
In this case, it should have the signature
``objective(y_true, y_pred) -> grad, hess`` or
``objective(y_true, y_pred, group) -> grad, hess``:
``objective(y_true, y_pred) -> grad, hess``,
``objective(y_true, y_pred, weight) -> grad, hess``
or ``objective(y_true, y_pred, weight, group) -> grad, hess``:
y_true : numpy 1-D array of shape = [n_samples]
The target values.
y_pred : numpy 1-D array of shape = [n_samples] or numpy 2-D array of shape = [n_samples, n_classes] (for multi-class task)
The predicted values.
Predicted values are returned before any transformation,
e.g. they are raw margin instead of probability of positive class for binary task.
weight : numpy 1-D array of shape = [n_samples]
The weight of samples. Weights should be non-negative.
group : numpy 1-D array
Group/query data.
Only used in the learning-to-rank task.
Expand Down
25 changes: 20 additions & 5 deletions tests/python_package_test/test_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -2433,14 +2433,20 @@ def test_default_objective_and_metric():
assert len(evals_result['valid_0']['l2']) == 5


def test_multiclass_custom_objective():
@pytest.mark.parametrize('use_weight', [True, False])
def test_multiclass_custom_objective(use_weight):
def custom_obj(y_pred, ds):
y_true = ds.get_label()
return sklearn_multiclass_custom_objective(y_true, y_pred)
weight = ds.get_weight()
grad, hess = sklearn_multiclass_custom_objective(y_true, y_pred, weight)
return grad, hess

centers = [[-4, -4], [4, 4], [-4, 4]]
X, y = make_blobs(n_samples=1_000, centers=centers, random_state=42)
weight = np.full_like(y, 2)
ds = lgb.Dataset(X, y)
if use_weight:
ds.set_weight(weight)
params = {'objective': 'multiclass', 'num_class': 3, 'num_leaves': 7}
builtin_obj_bst = lgb.train(params, ds, num_boost_round=10)
builtin_obj_preds = builtin_obj_bst.predict(X)
Expand All @@ -2452,16 +2458,25 @@ def custom_obj(y_pred, ds):
np.testing.assert_allclose(builtin_obj_preds, custom_obj_preds, rtol=0.01)


def test_multiclass_custom_eval():
@pytest.mark.parametrize('use_weight', [True, False])
def test_multiclass_custom_eval(use_weight):
def custom_eval(y_pred, ds):
y_true = ds.get_label()
return 'custom_logloss', log_loss(y_true, y_pred), False
weight = ds.get_weight() # weight is None when not set
loss = log_loss(y_true, y_pred, sample_weight=weight)
return 'custom_logloss', loss, False

centers = [[-4, -4], [4, 4], [-4, 4]]
X, y = make_blobs(n_samples=1_000, centers=centers, random_state=42)
X_train, X_valid, y_train, y_valid = train_test_split(X, y, test_size=0.2, random_state=0)
weight = np.full_like(y, 2)
X_train, X_valid, y_train, y_valid, weight_train, weight_valid = train_test_split(
X, y, weight, test_size=0.2, random_state=0
)
train_ds = lgb.Dataset(X_train, y_train)
valid_ds = lgb.Dataset(X_valid, y_valid, reference=train_ds)
if use_weight:
train_ds.set_weight(weight_train)
valid_ds.set_weight(weight_valid)
params = {'objective': 'multiclass', 'num_class': 3, 'num_leaves': 7}
eval_result = {}
bst = lgb.train(
Expand Down
48 changes: 45 additions & 3 deletions tests/python_package_test/test_sklearn.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import itertools
import math
import re
from functools import partial
from os import getenv
from pathlib import Path

Expand Down Expand Up @@ -1285,23 +1286,64 @@ def test_training_succeeds_when_data_is_dataframe_and_label_is_column_array(task
np.testing.assert_array_equal(preds_1d, preds_2d)


def test_multiclass_custom_objective():
@pytest.mark.parametrize('use_weight', [True, False])
def test_multiclass_custom_objective(use_weight):
centers = [[-4, -4], [4, 4], [-4, 4]]
X, y = make_blobs(n_samples=1_000, centers=centers, random_state=42)
weight = np.full_like(y, 2) if use_weight else None
params = {'n_estimators': 10, 'num_leaves': 7}
builtin_obj_model = lgb.LGBMClassifier(**params)
builtin_obj_model.fit(X, y)
builtin_obj_model.fit(X, y, sample_weight=weight)
builtin_obj_preds = builtin_obj_model.predict_proba(X)

custom_obj_model = lgb.LGBMClassifier(objective=sklearn_multiclass_custom_objective, **params)
custom_obj_model.fit(X, y)
custom_obj_model.fit(X, y, sample_weight=weight)
custom_obj_preds = softmax(custom_obj_model.predict(X, raw_score=True))

np.testing.assert_allclose(builtin_obj_preds, custom_obj_preds, rtol=0.01)
assert not callable(builtin_obj_model.objective_)
assert callable(custom_obj_model.objective_)


@pytest.mark.parametrize('use_weight', [True, False])
def test_multiclass_custom_eval(use_weight):
def custom_eval(y_true, y_pred, weight):
loss = log_loss(y_true, y_pred, sample_weight=weight)
return 'custom_logloss', loss, False

centers = [[-4, -4], [4, 4], [-4, 4]]
X, y = make_blobs(n_samples=1_000, centers=centers, random_state=42)
train_test_split_func = partial(train_test_split, test_size=0.2, random_state=0)
X_train, X_valid, y_train, y_valid = train_test_split_func(X, y)
if use_weight:
weight = np.full_like(y, 2)
weight_train, weight_valid = train_test_split_func(weight)
else:
weight_train = None
weight_valid = None
params = {'objective': 'multiclass', 'num_class': 3, 'num_leaves': 7}
model = lgb.LGBMClassifier(**params)
model.fit(
X_train,
y_train,
sample_weight=weight_train,
eval_set=[(X_train, y_train), (X_valid, y_valid)],
eval_names=['train', 'valid'],
eval_sample_weight=[weight_train, weight_valid],
eval_metric=custom_eval,
)
eval_result = model.evals_result_
train_ds = (X_train, y_train, weight_train)
valid_ds = (X_valid, y_valid, weight_valid)
for key, (X, y_true, weight) in zip(['train', 'valid'], [train_ds, valid_ds]):
np.testing.assert_allclose(
eval_result[key]['multi_logloss'], eval_result[key]['custom_logloss']
)
y_pred = model.predict_proba(X)
_, metric_value, _ = custom_eval(y_true, y_pred, weight)
np.testing.assert_allclose(metric_value, eval_result[key]['custom_logloss'][-1])


def test_negative_n_jobs(tmp_path):
n_threads = joblib.cpu_count()
if n_threads <= 1:
Expand Down
6 changes: 5 additions & 1 deletion tests/python_package_test/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -140,14 +140,18 @@ def logistic_sigmoid(x):
return 1.0 / (1.0 + np.exp(-x))


def sklearn_multiclass_custom_objective(y_true, y_pred):
def sklearn_multiclass_custom_objective(y_true, y_pred, weight=None):
num_rows, num_class = y_pred.shape
prob = softmax(y_pred)
grad_update = np.zeros_like(prob)
grad_update[np.arange(num_rows), y_true.astype(np.int32)] = -1.0
grad = prob + grad_update
factor = num_class / (num_class - 1)
hess = factor * prob * (1 - prob)
if weight is not None:
weight2d = weight.reshape(-1, 1)
grad *= weight2d
hess *= weight2d
return grad, hess


Expand Down

0 comments on commit b6deb9a

Please sign in to comment.