Files
2026-07-13 13:27:18 +08:00

1702 lines
71 KiB
Python

# coding: utf-8
"""Tests for lightgbm.dask module"""
import inspect
import re
import socket
from itertools import groupby
from sys import platform
from urllib.parse import urlparse
import pytest
from sklearn.metrics import accuracy_score, r2_score
import lightgbm as lgb
from .utils import (
BuildInfo,
np_assert_array_equal,
sklearn_multiclass_custom_objective,
)
if platform in {"cygwin", "win32"}:
pytest.skip("lightgbm.dask is not currently supported on Windows", allow_module_level=True)
dask = pytest.importorskip("dask")
import dask.array as da
import dask.dataframe as dd
import numpy as np
import pandas as pd
import sklearn.utils.estimator_checks as sklearn_checks
from dask.array.utils import assert_eq
from dask.distributed import Client, LocalCluster, default_client, wait
from scipy.sparse import csc_matrix, csr_matrix
from scipy.stats import spearmanr
from sklearn.datasets import make_blobs, make_regression
from .utils import make_ranking, pickle_obj, unpickle_obj
tasks = ["binary-classification", "multiclass-classification", "regression", "ranking"]
distributed_training_algorithms = ["data", "voting"]
data_output = ["array", "scipy_csr_matrix", "dataframe", "dataframe-with-categorical"]
boosting_types = ["gbdt", "dart", "goss", "rf"]
group_sizes = [5, 5, 5, 10, 10, 10, 20, 20, 20, 50, 50]
task_to_dask_factory = {
"regression": lgb.DaskLGBMRegressor,
"binary-classification": lgb.DaskLGBMClassifier,
"multiclass-classification": lgb.DaskLGBMClassifier,
"ranking": lgb.DaskLGBMRanker,
}
task_to_local_factory = {
"regression": lgb.LGBMRegressor,
"binary-classification": lgb.LGBMClassifier,
"multiclass-classification": lgb.LGBMClassifier,
"ranking": lgb.LGBMRanker,
}
pytestmark = [
pytest.mark.skipif(BuildInfo.has_cuda, reason="Fails to run with CUDA interface"),
pytest.mark.skipif(BuildInfo.has_gpu, reason="Fails to run with GPU interface"),
pytest.mark.skipif(BuildInfo.has_mpi, reason="Fails to run with MPI interface"),
]
@pytest.fixture(scope="module")
def cluster():
dask_cluster = LocalCluster(n_workers=2, threads_per_worker=2, dashboard_address=None)
yield dask_cluster
dask_cluster.close()
@pytest.fixture(scope="module")
def cluster2():
dask_cluster = LocalCluster(n_workers=2, threads_per_worker=2, dashboard_address=None)
yield dask_cluster
dask_cluster.close()
@pytest.fixture(scope="module")
def cluster_three_workers():
dask_cluster = LocalCluster(n_workers=3, threads_per_worker=1, dashboard_address=None)
yield dask_cluster
dask_cluster.close()
@pytest.fixture
def listen_port():
listen_port.port += 10
return listen_port.port
listen_port.port = 13000
def _get_workers_hostname(cluster: LocalCluster) -> str:
one_worker_address = next(iter(cluster.scheduler_info["workers"]))
return urlparse(one_worker_address).hostname
def _create_ranking_data(n_samples=100, output="array", chunk_size=50, **kwargs):
X, y, g = make_ranking(n_samples=n_samples, random_state=42, **kwargs)
rnd = np.random.RandomState(42)
w = rnd.rand(X.shape[0]) * 0.01
g_rle = np.array([len(list(grp)) for _, grp in groupby(g)])
if output.startswith("dataframe"):
# add target, weight, and group to DataFrame so that partitions abide by group boundaries.
X_df = pd.DataFrame(X, columns=[f"feature_{i}" for i in range(X.shape[1])])
if output == "dataframe-with-categorical":
for i in range(5):
col_name = f"cat_col{i}"
cat_values = rnd.choice(["a", "b"], X.shape[0])
cat_series = pd.Series(cat_values, dtype="category")
X_df[col_name] = cat_series
X = X_df.copy()
X_df = X_df.assign(y=y, g=g, w=w)
# set_index ensures partitions are based on group id.
# See https://stackoverflow.com/questions/49532824/dask-dataframe-split-partitions-based-on-a-column-or-function.
X_df.set_index("g", inplace=True)
dX = dd.from_pandas(X_df, chunksize=chunk_size)
# separate target, weight from features.
dy = dX["y"]
dw = dX["w"]
dX = dX.drop(columns=["y", "w"])
dg = dX.index.to_series()
# encode group identifiers into run-length encoding, the format LightGBMRanker is expecting
# so that within each partition, sum(g) = n_samples.
dg = dg.map_partitions(lambda p: p.groupby("g", sort=False).apply(lambda z: z.shape[0]))
elif output == "array":
# ranking arrays: one chunk per group. Each chunk must include all columns.
p = X.shape[1]
dX, dy, dw, dg = [], [], [], []
for g_idx, rhs in enumerate(np.cumsum(g_rle)):
lhs = rhs - g_rle[g_idx]
dX.append(da.from_array(X[lhs:rhs, :], chunks=(rhs - lhs, p)))
dy.append(da.from_array(y[lhs:rhs]))
dw.append(da.from_array(w[lhs:rhs]))
dg.append(da.from_array(np.array([g_rle[g_idx]])))
dX = da.concatenate(dX, axis=0)
dy = da.concatenate(dy, axis=0)
dw = da.concatenate(dw, axis=0)
dg = da.concatenate(dg, axis=0)
else:
raise ValueError("Ranking data creation only supported for Dask arrays and dataframes")
return X, y, w, g_rle, dX, dy, dw, dg
def _create_data(objective, n_samples=1_000, output="array", chunk_size=500, **kwargs):
if objective.endswith("classification"):
if objective == "binary-classification":
centers = [[-4, -4], [4, 4]]
elif objective == "multiclass-classification":
centers = [[-4, -4], [4, 4], [-4, 4]]
else:
raise ValueError(f"Unknown classification task '{objective}'")
X, y = make_blobs(n_samples=n_samples, centers=centers, random_state=42)
elif objective == "regression":
X, y = make_regression(n_samples=n_samples, n_features=4, n_informative=2, random_state=42)
elif objective == "ranking":
return _create_ranking_data(n_samples=n_samples, output=output, chunk_size=chunk_size, **kwargs)
else:
raise ValueError(f"Unknown objective '{objective}'")
rnd = np.random.RandomState(42)
weights = rnd.random(X.shape[0]) * 0.01
if output == "array":
dX = da.from_array(X, (chunk_size, X.shape[1]))
dy = da.from_array(y, chunk_size)
dw = da.from_array(weights, chunk_size)
elif output.startswith("dataframe"):
X_df = pd.DataFrame(X, columns=[f"feature_{i}" for i in range(X.shape[1])])
if output == "dataframe-with-categorical":
num_cat_cols = 2
for i in range(num_cat_cols):
col_name = f"cat_col{i}"
cat_values = rnd.choice(["a", "b"], X.shape[0])
cat_series = pd.Series(cat_values, dtype="category")
X_df[col_name] = cat_series
X = np.hstack((X, cat_series.cat.codes.values.reshape(-1, 1)))
# make one categorical feature relevant to the target
cat_col_is_a = X_df["cat_col0"] == "a"
if objective == "regression":
y = np.where(cat_col_is_a, y, 2 * y)
elif objective == "binary-classification":
y = np.where(cat_col_is_a, y, 1 - y)
elif objective == "multiclass-classification":
n_classes = 3
y = np.where(cat_col_is_a, y, (1 + y) % n_classes)
y_df = pd.Series(y, name="target")
dX = dd.from_pandas(X_df, chunksize=chunk_size)
dy = dd.from_pandas(y_df, chunksize=chunk_size)
dw = dd.from_array(weights, chunksize=chunk_size)
elif output == "scipy_csr_matrix":
dX = da.from_array(X, chunks=(chunk_size, X.shape[1])).map_blocks(csr_matrix)
dy = da.from_array(y, chunks=chunk_size)
dw = da.from_array(weights, chunk_size)
X = csr_matrix(X)
elif output == "scipy_csc_matrix":
dX = da.from_array(X, chunks=(chunk_size, X.shape[1])).map_blocks(csc_matrix)
dy = da.from_array(y, chunks=chunk_size)
dw = da.from_array(weights, chunk_size)
X = csc_matrix(X)
else:
raise ValueError(f"Unknown output type '{output}'")
return X, y, weights, None, dX, dy, dw, None
def _r2_score(dy_true, dy_pred):
y_true = dy_true.compute()
y_pred = dy_pred.compute()
numerator = ((y_true - y_pred) ** 2).sum(axis=0)
denominator = ((y_true - y_true.mean(axis=0)) ** 2).sum(axis=0)
return 1 - numerator / denominator
def _accuracy_score(dy_true, dy_pred):
y_true = dy_true.compute()
y_pred = dy_pred.compute()
return (y_true == y_pred).mean()
def _constant_metric(y_true, y_pred):
metric_name = "constant_metric"
value = 0.708
maximize = False
return metric_name, value, maximize
def _objective_least_squares(y_true, y_pred):
grad = y_pred - y_true
hess = np.ones(len(y_true))
return grad, hess
def _objective_logistic_regression(y_true, y_pred):
y_pred = 1.0 / (1.0 + np.exp(-y_pred))
grad = y_pred - y_true
hess = y_pred * (1.0 - y_pred)
return grad, hess
@pytest.mark.parametrize("output", data_output)
@pytest.mark.parametrize("task", ["binary-classification", "multiclass-classification"])
@pytest.mark.parametrize("boosting_type", boosting_types)
@pytest.mark.parametrize("tree_learner", distributed_training_algorithms)
def test_classifier(output, task, boosting_type, tree_learner, cluster):
with Client(cluster) as client:
X, y, w, _, dX, dy, dw, _ = _create_data(objective=task, output=output)
params = {"boosting_type": boosting_type, "tree_learner": tree_learner, "n_estimators": 50, "num_leaves": 31}
if boosting_type == "rf":
params.update(
{
"bagging_freq": 1,
"bagging_fraction": 0.9,
}
)
elif boosting_type == "goss":
params["top_rate"] = 0.5
dask_classifier = lgb.DaskLGBMClassifier(client=client, time_out=5, **params)
dask_classifier = dask_classifier.fit(dX, dy, sample_weight=dw)
p1 = dask_classifier.predict(dX)
p1_raw = dask_classifier.predict(dX, raw_score=True).compute()
p1_first_iter_raw = dask_classifier.predict(dX, start_iteration=0, num_iteration=1, raw_score=True).compute()
p1_early_stop_raw = dask_classifier.predict(
dX, pred_early_stop=True, pred_early_stop_margin=1.0, pred_early_stop_freq=2, raw_score=True
).compute()
p1_proba = dask_classifier.predict_proba(dX).compute()
p1_pred_leaf = dask_classifier.predict(dX, pred_leaf=True)
p1_local = dask_classifier.to_local().predict(X)
s1 = _accuracy_score(dy, p1)
p1 = p1.compute()
local_classifier = lgb.LGBMClassifier(**params)
local_classifier.fit(X, y, sample_weight=w)
p2 = local_classifier.predict(X)
p2_proba = local_classifier.predict_proba(X)
s2 = local_classifier.score(X, y)
if boosting_type == "rf":
# https://github.com/lightgbm-org/LightGBM/issues/4118
assert_eq(s1, s2, atol=0.01)
assert_eq(p1_proba, p2_proba, atol=0.8)
else:
assert_eq(s1, s2)
assert_eq(p1, p2)
assert_eq(p1, y)
assert_eq(p2, y)
assert_eq(p1_proba, p2_proba, atol=0.03)
assert_eq(p1_local, p2)
assert_eq(p1_local, y)
# extra predict() parameters should be passed through correctly
with pytest.raises(AssertionError): # noqa: PT011
assert_eq(p1_raw, p1_first_iter_raw)
with pytest.raises(AssertionError): # noqa: PT011
assert_eq(p1_raw, p1_early_stop_raw)
# pref_leaf values should have the right shape
# and values that look like valid tree nodes
pred_leaf_vals = p1_pred_leaf.compute()
assert pred_leaf_vals.shape == (X.shape[0], dask_classifier.booster_.num_trees())
assert np.max(pred_leaf_vals) <= params["num_leaves"]
assert np.min(pred_leaf_vals) >= 0
assert len(np.unique(pred_leaf_vals)) <= params["num_leaves"]
# be sure LightGBM actually used at least one categorical column,
# and that it was correctly treated as a categorical feature
if output == "dataframe-with-categorical":
cat_cols = [col for col in dX.columns if dX.dtypes[col].name == "category"]
tree_df = dask_classifier.booster_.trees_to_dataframe()
node_uses_cat_col = tree_df["split_feature"].isin(cat_cols)
assert node_uses_cat_col.sum() > 0
assert tree_df.loc[node_uses_cat_col, "decision_type"].unique()[0] == "=="
@pytest.mark.parametrize("output", data_output + ["scipy_csc_matrix"])
@pytest.mark.parametrize("task", ["binary-classification", "multiclass-classification"])
def test_classifier_pred_contrib(output, task, cluster):
with Client(cluster) as client:
X, y, w, _, dX, dy, dw, _ = _create_data(objective=task, output=output)
params = {"n_estimators": 10, "num_leaves": 10}
dask_classifier = lgb.DaskLGBMClassifier(client=client, time_out=5, tree_learner="data", **params)
dask_classifier = dask_classifier.fit(dX, dy, sample_weight=dw)
preds_with_contrib = dask_classifier.predict(dX, pred_contrib=True)
local_classifier = lgb.LGBMClassifier(**params)
local_classifier.fit(X, y, sample_weight=w)
local_preds_with_contrib = local_classifier.predict(X, pred_contrib=True)
# shape depends on whether it is binary or multiclass classification
num_features = dask_classifier.n_features_
num_classes = dask_classifier.n_classes_
if num_classes == 2:
expected_num_cols = num_features + 1
else:
expected_num_cols = (num_features + 1) * num_classes
# in the special case of multi-class classification using scipy sparse matrices,
# the output of `.predict(..., pred_contrib=True)` is a list of sparse matrices (one per class)
#
# since that case is so different than all other cases, check the relevant things here
# and then return early
if output.startswith("scipy") and task == "multiclass-classification":
if output == "scipy_csr_matrix":
expected_type = csr_matrix
elif output == "scipy_csc_matrix":
expected_type = csc_matrix
else:
raise ValueError(f"Unrecognized output type: {output}")
assert isinstance(preds_with_contrib, list)
assert all(isinstance(arr, da.Array) for arr in preds_with_contrib)
assert all(isinstance(arr._meta, expected_type) for arr in preds_with_contrib)
assert len(preds_with_contrib) == num_classes
assert len(preds_with_contrib) == len(local_preds_with_contrib)
for i in range(num_classes):
computed_preds = preds_with_contrib[i].compute()
assert isinstance(computed_preds, expected_type)
assert computed_preds.shape[1] == num_classes
assert computed_preds.shape == local_preds_with_contrib[i].shape
assert len(np.unique(computed_preds[:, -1])) == 1
# raw scores will probably be different, but at least check that all predicted classes are the same
pred_classes = np.argmax(computed_preds.toarray(), axis=1)
local_pred_classes = np.argmax(local_preds_with_contrib[i].toarray(), axis=1)
np_assert_array_equal(pred_classes, local_pred_classes, strict=True)
return
preds_with_contrib = preds_with_contrib.compute()
if output.startswith("scipy"):
preds_with_contrib = preds_with_contrib.toarray()
# be sure LightGBM actually used at least one categorical column,
# and that it was correctly treated as a categorical feature
if output == "dataframe-with-categorical":
cat_cols = [col for col in dX.columns if dX.dtypes[col].name == "category"]
tree_df = dask_classifier.booster_.trees_to_dataframe()
node_uses_cat_col = tree_df["split_feature"].isin(cat_cols)
assert node_uses_cat_col.sum() > 0
assert tree_df.loc[node_uses_cat_col, "decision_type"].unique()[0] == "=="
# * shape depends on whether it is binary or multiclass classification
# * matrix for binary classification is of the form [feature_contrib, base_value],
# for multi-class it's [feat_contrib_class1, base_value_class1, feat_contrib_class2, base_value_class2, etc.]
# * contrib outputs for distributed training are different than from local training, so we can just test
# that the output has the right shape and base values are in the right position
assert preds_with_contrib.shape[1] == expected_num_cols
assert preds_with_contrib.shape == local_preds_with_contrib.shape
if num_classes == 2:
assert len(np.unique(preds_with_contrib[:, num_features])) == 1
else:
for i in range(num_classes):
base_value_col = num_features * (i + 1) + i
assert len(np.unique(preds_with_contrib[:, base_value_col]) == 1)
@pytest.mark.parametrize("output", data_output)
@pytest.mark.parametrize("task", ["binary-classification", "multiclass-classification"])
def test_classifier_custom_objective(output, task, cluster):
with Client(cluster) as client:
X, y, w, _, dX, dy, dw, _ = _create_data(
objective=task,
output=output,
)
params = {
"n_estimators": 50,
"num_leaves": 31,
"verbose": -1,
"seed": 708,
"deterministic": True,
"force_col_wise": True,
}
if task == "binary-classification":
params.update(
{
"objective": _objective_logistic_regression,
}
)
elif task == "multiclass-classification":
params.update({"objective": sklearn_multiclass_custom_objective, "num_classes": 3})
dask_classifier = lgb.DaskLGBMClassifier(client=client, time_out=5, tree_learner="data", **params)
dask_classifier = dask_classifier.fit(dX, dy, sample_weight=dw)
dask_classifier_local = dask_classifier.to_local()
p1_raw = dask_classifier.predict(dX, raw_score=True).compute()
p1_raw_local = dask_classifier_local.predict(X, raw_score=True)
local_classifier = lgb.LGBMClassifier(**params)
local_classifier.fit(X, y, sample_weight=w)
p2_raw = local_classifier.predict(X, raw_score=True)
# with a custom objective, prediction result is a raw score instead of predicted class
if task == "binary-classification":
p1_proba = 1.0 / (1.0 + np.exp(-p1_raw))
p1_class = (p1_proba > 0.5).astype(np.int64)
p1_proba_local = 1.0 / (1.0 + np.exp(-p1_raw_local))
p1_class_local = (p1_proba_local > 0.5).astype(np.int64)
p2_proba = 1.0 / (1.0 + np.exp(-p2_raw))
p2_class = (p2_proba > 0.5).astype(np.int64)
elif task == "multiclass-classification":
p1_proba = np.exp(p1_raw) / np.sum(np.exp(p1_raw), axis=1).reshape(-1, 1)
p1_class = p1_proba.argmax(axis=1)
p1_proba_local = np.exp(p1_raw_local) / np.sum(np.exp(p1_raw_local), axis=1).reshape(-1, 1)
p1_class_local = p1_proba_local.argmax(axis=1)
p2_proba = np.exp(p2_raw) / np.sum(np.exp(p2_raw), axis=1).reshape(-1, 1)
p2_class = p2_proba.argmax(axis=1)
# function should have been preserved
assert callable(dask_classifier.objective_)
assert callable(dask_classifier_local.objective_)
# should correctly classify every sample
assert_eq(p1_class, y)
assert_eq(p1_class_local, y)
assert_eq(p2_class, y)
# probability estimates should be similar
assert_eq(p1_proba, p2_proba, atol=0.03)
assert_eq(p1_proba, p1_proba_local)
def test_machines_to_worker_map_unparsable_host_names():
workers = {"0.0.0.1:80": {}, "0.0.0.2:80": {}}
machines = "0.0.0.1:80,0.0.0.2:80"
with pytest.raises(ValueError, match="Could not parse host name from worker address '0.0.0.1:80'"):
lgb.dask._machines_to_worker_map(machines=machines, worker_addresses=workers.keys())
def test_training_does_not_fail_on_port_conflicts(cluster):
with Client(cluster) as client:
_, _, _, _, dX, dy, dw, _ = _create_data("binary-classification", output="array")
lightgbm_default_port = 12400
workers_hostname = _get_workers_hostname(cluster)
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind((workers_hostname, lightgbm_default_port))
dask_classifier = lgb.DaskLGBMClassifier(client=client, time_out=5, n_estimators=5, num_leaves=5)
for _ in range(5):
dask_classifier.fit(
X=dX,
y=dy,
sample_weight=dw,
)
assert dask_classifier.booster_
@pytest.mark.parametrize("output", data_output)
@pytest.mark.parametrize("boosting_type", boosting_types)
@pytest.mark.parametrize("tree_learner", distributed_training_algorithms)
def test_regressor(output, boosting_type, tree_learner, cluster):
with Client(cluster) as client:
X, y, w, _, dX, dy, dw, _ = _create_data(objective="regression", output=output)
params = {
"boosting_type": boosting_type,
"random_state": 42,
"num_leaves": 31,
"n_estimators": 20,
}
if boosting_type == "rf":
params.update(
{
"bagging_freq": 1,
"bagging_fraction": 0.9,
}
)
dask_regressor = lgb.DaskLGBMRegressor(client=client, time_out=5, tree=tree_learner, **params)
dask_regressor = dask_regressor.fit(dX, dy, sample_weight=dw)
p1 = dask_regressor.predict(dX)
p1_pred_leaf = dask_regressor.predict(dX, pred_leaf=True)
s1 = _r2_score(dy, p1)
p1 = p1.compute()
p1_raw = dask_regressor.predict(dX, raw_score=True).compute()
p1_first_iter_raw = dask_regressor.predict(dX, start_iteration=0, num_iteration=1, raw_score=True).compute()
p1_local = dask_regressor.to_local().predict(X)
s1_local = dask_regressor.to_local().score(X, y)
local_regressor = lgb.LGBMRegressor(**params)
local_regressor.fit(X, y, sample_weight=w)
s2 = local_regressor.score(X, y)
p2 = local_regressor.predict(X)
# Scores should be the same
assert_eq(s1, s2, atol=0.01)
assert_eq(s1, s1_local)
# Predictions should be roughly the same.
assert_eq(p1, p1_local)
# pref_leaf values should have the right shape
# and values that look like valid tree nodes
pred_leaf_vals = p1_pred_leaf.compute()
assert pred_leaf_vals.shape == (X.shape[0], dask_regressor.booster_.num_trees())
assert np.max(pred_leaf_vals) <= params["num_leaves"]
assert np.min(pred_leaf_vals) >= 0
assert len(np.unique(pred_leaf_vals)) <= params["num_leaves"]
assert_eq(p1, y, rtol=0.5, atol=50.0)
assert_eq(p2, y, rtol=0.5, atol=50.0)
# extra predict() parameters should be passed through correctly
with pytest.raises(AssertionError): # noqa: PT011
assert_eq(p1_raw, p1_first_iter_raw)
# be sure LightGBM actually used at least one categorical column,
# and that it was correctly treated as a categorical feature
if output == "dataframe-with-categorical":
cat_cols = [col for col in dX.columns if dX.dtypes[col].name == "category"]
tree_df = dask_regressor.booster_.trees_to_dataframe()
node_uses_cat_col = tree_df["split_feature"].isin(cat_cols)
assert node_uses_cat_col.sum() > 0
assert tree_df.loc[node_uses_cat_col, "decision_type"].unique()[0] == "=="
@pytest.mark.parametrize("output", data_output)
def test_regressor_pred_contrib(output, cluster):
with Client(cluster) as client:
X, y, w, _, dX, dy, dw, _ = _create_data(objective="regression", output=output)
params = {"n_estimators": 10, "num_leaves": 10}
dask_regressor = lgb.DaskLGBMRegressor(client=client, time_out=5, tree_learner="data", **params)
dask_regressor = dask_regressor.fit(dX, dy, sample_weight=dw)
preds_with_contrib = dask_regressor.predict(dX, pred_contrib=True).compute()
local_regressor = lgb.LGBMRegressor(**params)
local_regressor.fit(X, y, sample_weight=w)
local_preds_with_contrib = local_regressor.predict(X, pred_contrib=True)
if output == "scipy_csr_matrix":
preds_with_contrib = preds_with_contrib.toarray()
# contrib outputs for distributed training are different than from local training, so we can just test
# that the output has the right shape and base values are in the right position
num_features = dX.shape[1]
assert preds_with_contrib.shape[1] == num_features + 1
assert preds_with_contrib.shape == local_preds_with_contrib.shape
# be sure LightGBM actually used at least one categorical column,
# and that it was correctly treated as a categorical feature
if output == "dataframe-with-categorical":
cat_cols = [col for col in dX.columns if dX.dtypes[col].name == "category"]
tree_df = dask_regressor.booster_.trees_to_dataframe()
node_uses_cat_col = tree_df["split_feature"].isin(cat_cols)
assert node_uses_cat_col.sum() > 0
assert tree_df.loc[node_uses_cat_col, "decision_type"].unique()[0] == "=="
@pytest.mark.parametrize("output", data_output)
@pytest.mark.parametrize("alpha", [0.1, 0.5, 0.9])
def test_regressor_quantile(output, alpha, cluster):
with Client(cluster) as client:
X, y, w, _, dX, dy, dw, _ = _create_data(objective="regression", output=output)
params = {"objective": "quantile", "alpha": alpha, "random_state": 42, "n_estimators": 10, "num_leaves": 10}
dask_regressor = lgb.DaskLGBMRegressor(client=client, tree_learner_type="data_parallel", **params)
dask_regressor = dask_regressor.fit(dX, dy, sample_weight=dw)
p1 = dask_regressor.predict(dX).compute()
q1 = np.count_nonzero(y < p1) / y.shape[0]
local_regressor = lgb.LGBMRegressor(**params)
local_regressor.fit(X, y, sample_weight=w)
p2 = local_regressor.predict(X)
q2 = np.count_nonzero(y < p2) / y.shape[0]
# Quantiles should be right
np.testing.assert_allclose(q1, alpha, atol=0.2)
np.testing.assert_allclose(q2, alpha, atol=0.2)
# be sure LightGBM actually used at least one categorical column,
# and that it was correctly treated as a categorical feature
if output == "dataframe-with-categorical":
cat_cols = [col for col in dX.columns if dX.dtypes[col].name == "category"]
tree_df = dask_regressor.booster_.trees_to_dataframe()
node_uses_cat_col = tree_df["split_feature"].isin(cat_cols)
assert node_uses_cat_col.sum() > 0
assert tree_df.loc[node_uses_cat_col, "decision_type"].unique()[0] == "=="
@pytest.mark.parametrize("output", data_output)
def test_regressor_custom_objective(output, cluster):
with Client(cluster) as client:
X, y, w, _, dX, dy, dw, _ = _create_data(objective="regression", output=output)
params = {"n_estimators": 10, "num_leaves": 10, "objective": _objective_least_squares}
dask_regressor = lgb.DaskLGBMRegressor(client=client, time_out=5, tree_learner="data", **params)
dask_regressor = dask_regressor.fit(dX, dy, sample_weight=dw)
dask_regressor_local = dask_regressor.to_local()
p1 = dask_regressor.predict(dX)
p1_local = dask_regressor_local.predict(X)
s1_local = dask_regressor_local.score(X, y)
s1 = _r2_score(dy, p1)
p1 = p1.compute()
local_regressor = lgb.LGBMRegressor(**params)
local_regressor.fit(X, y, sample_weight=w)
p2 = local_regressor.predict(X)
s2 = local_regressor.score(X, y)
# function should have been preserved
assert callable(dask_regressor.objective_)
assert callable(dask_regressor_local.objective_)
# Scores should be the same
assert_eq(s1, s2, atol=0.01)
assert_eq(s1, s1_local)
# local and Dask predictions should be the same
assert_eq(p1, p1_local)
# predictions should be better than random
assert_precision = {"rtol": 0.5, "atol": 50.0}
assert_eq(p1, y, **assert_precision)
assert_eq(p2, y, **assert_precision)
@pytest.mark.xfail(
platform.lower().startswith("darwin"),
reason=(
"learning-to-rank Dask tests are unreliable on macOS. "
"See https://github.com/lightgbm-org/LightGBM/issues/4074#issuecomment-3124996317"
),
)
@pytest.mark.parametrize("output", ["array", "dataframe", "dataframe-with-categorical"])
@pytest.mark.parametrize("group", [None, group_sizes])
@pytest.mark.parametrize("boosting_type", boosting_types)
@pytest.mark.parametrize("tree_learner", distributed_training_algorithms)
def test_ranker(output, group, boosting_type, tree_learner, cluster):
with Client(cluster) as client:
if output == "dataframe-with-categorical":
X, y, w, g, dX, dy, dw, dg = _create_data(
objective="ranking", output=output, group=group, n_features=1, n_informative=1
)
else:
X, y, w, g, dX, dy, dw, dg = _create_data(objective="ranking", output=output, group=group)
# rebalance small dask.Array dataset for better performance.
if output == "array":
dX = dX.persist()
dy = dy.persist()
dw = dw.persist()
dg = dg.persist()
_ = wait([dX, dy, dw, dg])
client.rebalance()
# use many trees + leaves to overfit, help ensure that Dask data-parallel strategy matches that of
# serial learner. See https://github.com/lightgbm-org/LightGBM/issues/3292#issuecomment-671288210.
params = {
"boosting_type": boosting_type,
"random_state": 42,
"n_estimators": 50,
"num_leaves": 20,
"min_child_samples": 1,
}
if boosting_type == "rf":
params.update(
{
"bagging_freq": 1,
"bagging_fraction": 0.9,
}
)
dask_ranker = lgb.DaskLGBMRanker(client=client, time_out=5, tree_learner_type=tree_learner, **params)
dask_ranker = dask_ranker.fit(dX, dy, sample_weight=dw, group=dg)
rnkvec_dask = dask_ranker.predict(dX)
rnkvec_dask = rnkvec_dask.compute()
p1_pred_leaf = dask_ranker.predict(dX, pred_leaf=True)
p1_raw = dask_ranker.predict(dX, raw_score=True).compute()
p1_first_iter_raw = dask_ranker.predict(dX, start_iteration=0, num_iteration=1, raw_score=True).compute()
p1_early_stop_raw = dask_ranker.predict(
dX, pred_early_stop=True, pred_early_stop_margin=1.0, pred_early_stop_freq=2, raw_score=True
).compute()
rnkvec_dask_local = dask_ranker.to_local().predict(X)
local_ranker = lgb.LGBMRanker(**params)
local_ranker.fit(X, y, sample_weight=w, group=g)
rnkvec_local = local_ranker.predict(X)
# distributed ranker should be able to rank decently well and should
# have high rank correlation with scores from serial ranker.
dcor = spearmanr(rnkvec_dask, y).correlation
assert dcor > 0.6
assert spearmanr(rnkvec_dask, rnkvec_local).correlation > 0.8
assert_eq(rnkvec_dask, rnkvec_dask_local)
# extra predict() parameters should be passed through correctly
with pytest.raises(AssertionError): # noqa: PT011
assert_eq(p1_raw, p1_first_iter_raw)
with pytest.raises(AssertionError): # noqa: PT011
assert_eq(p1_raw, p1_early_stop_raw)
# pref_leaf values should have the right shape
# and values that look like valid tree nodes
pred_leaf_vals = p1_pred_leaf.compute()
assert pred_leaf_vals.shape == (X.shape[0], dask_ranker.booster_.num_trees())
assert np.max(pred_leaf_vals) <= params["num_leaves"]
assert np.min(pred_leaf_vals) >= 0
assert len(np.unique(pred_leaf_vals)) <= params["num_leaves"]
# be sure LightGBM actually used at least one categorical column,
# and that it was correctly treated as a categorical feature
if output == "dataframe-with-categorical":
cat_cols = [col for col in dX.columns if dX.dtypes[col].name == "category"]
tree_df = dask_ranker.booster_.trees_to_dataframe()
node_uses_cat_col = tree_df["split_feature"].isin(cat_cols)
assert node_uses_cat_col.sum() > 0
assert tree_df.loc[node_uses_cat_col, "decision_type"].unique()[0] == "=="
@pytest.mark.parametrize("output", ["array", "dataframe", "dataframe-with-categorical"])
def test_ranker_custom_objective(output, cluster):
with Client(cluster) as client:
if output == "dataframe-with-categorical":
X, y, w, g, dX, dy, dw, dg = _create_data(
objective="ranking", output=output, group=group_sizes, n_features=1, n_informative=1
)
else:
X, y, w, g, dX, dy, dw, dg = _create_data(objective="ranking", output=output, group=group_sizes)
# rebalance small dask.Array dataset for better performance.
if output == "array":
dX = dX.persist()
dy = dy.persist()
dw = dw.persist()
dg = dg.persist()
_ = wait([dX, dy, dw, dg])
client.rebalance()
params = {
"random_state": 42,
"n_estimators": 50,
"num_leaves": 20,
"min_child_samples": 1,
"objective": _objective_least_squares,
}
dask_ranker = lgb.DaskLGBMRanker(client=client, time_out=5, tree_learner_type="data", **params)
dask_ranker = dask_ranker.fit(dX, dy, sample_weight=dw, group=dg)
rnkvec_dask = dask_ranker.predict(dX).compute()
dask_ranker_local = dask_ranker.to_local()
rnkvec_dask_local = dask_ranker_local.predict(X)
local_ranker = lgb.LGBMRanker(**params)
local_ranker.fit(X, y, sample_weight=w, group=g)
rnkvec_local = local_ranker.predict(X)
# distributed ranker should be able to rank decently well with the least-squares objective
# and should have high rank correlation with scores from serial ranker.
assert spearmanr(rnkvec_dask, y).correlation > 0.6
assert spearmanr(rnkvec_dask, rnkvec_local).correlation > 0.8
assert_eq(rnkvec_dask, rnkvec_dask_local)
# function should have been preserved
assert callable(dask_ranker.objective_)
assert callable(dask_ranker_local.objective_)
@pytest.mark.parametrize("task", tasks)
@pytest.mark.parametrize("output", data_output)
@pytest.mark.parametrize("eval_sizes", [[0.5, 1, 1.5], [0]])
@pytest.mark.parametrize("eval_names_prefix", ["specified", None])
def test_eval_set_no_early_stopping(task, output, eval_sizes, eval_names_prefix, cluster):
if task == "ranking" and output == "scipy_csr_matrix":
pytest.skip("LGBMRanker is not currently tested on sparse matrices")
with Client(cluster) as client:
# Use larger trainset to prevent premature stopping due to zero loss, causing num_trees() < n_estimators.
# Use small chunk_size to avoid single-worker allocation of eval data partitions.
n_samples = 1000
chunk_size = 10
n_eval_sets = len(eval_sizes)
eval_set = []
eval_sample_weight = []
eval_class_weight = None
eval_init_score = None
if eval_names_prefix:
eval_names = [f"{eval_names_prefix}_{i}" for i in range(len(eval_sizes))]
else:
eval_names = None
X, y, w, g, dX, dy, dw, dg = _create_data(
objective=task, n_samples=n_samples, output=output, chunk_size=chunk_size
)
if task == "ranking":
eval_metrics = ["ndcg"]
eval_at = (5, 6)
eval_metric_names = [f"ndcg@{k}" for k in eval_at]
eval_group = []
else:
# test eval_class_weight, eval_init_score on binary-classification task.
# Note: objective's default `metric` will be evaluated in evals_result_ in addition to all eval_metrics.
if task == "binary-classification":
eval_metrics = ["binary_error", "auc"]
eval_metric_names = ["binary_logloss", "binary_error", "auc"]
eval_class_weight = []
eval_init_score = []
elif task == "multiclass-classification":
eval_metrics = ["multi_error"]
eval_metric_names = ["multi_logloss", "multi_error"]
elif task == "regression":
eval_metrics = ["l1"]
eval_metric_names = ["l2", "l1"]
# create eval_sets by creating new datasets or copying training data.
for eval_size in eval_sizes:
if eval_size == 1:
y_e = y
dX_e = dX
dy_e = dy
dw_e = dw
dg_e = dg
else:
n_eval_samples = max(chunk_size, int(n_samples * eval_size))
_, y_e, _, _, dX_e, dy_e, dw_e, dg_e = _create_data(
objective=task, n_samples=n_eval_samples, output=output, chunk_size=chunk_size
)
eval_set.append((dX_e, dy_e))
eval_sample_weight.append(dw_e)
if task == "ranking":
eval_group.append(dg_e)
if task == "binary-classification":
n_neg = np.sum(y_e == 0)
n_pos = np.sum(y_e == 1)
eval_class_weight.append({0: n_neg / n_pos, 1: n_pos / n_neg})
init_score_value = np.log(np.mean(y_e) / (1 - np.mean(y_e)))
if "dataframe" in output:
d_init_score = dy_e.map_partitions(lambda x, val=init_score_value: pd.Series([val] * x.size))
else:
d_init_score = dy_e.map_blocks(lambda x, val=init_score_value: np.repeat(val, x.size))
eval_init_score.append(d_init_score)
fit_trees = 50
params = {"random_state": 42, "n_estimators": fit_trees, "num_leaves": 2}
model_factory = task_to_dask_factory[task]
dask_model = model_factory(client=client, **params)
fit_params = {
"X": dX,
"y": dy,
"eval_set": eval_set,
"eval_names": eval_names,
"eval_sample_weight": eval_sample_weight,
"eval_init_score": eval_init_score,
"eval_metric": eval_metrics,
}
if task == "ranking":
fit_params.update({"group": dg, "eval_group": eval_group, "eval_at": eval_at})
elif task == "binary-classification":
fit_params.update({"eval_class_weight": eval_class_weight})
if eval_sizes == [0]:
with pytest.warns(
UserWarning,
match="Worker (.*) was not allocated eval_set data. Therefore evals_result_ and best_score_ data may be unreliable.",
):
dask_model.fit(**fit_params)
else:
dask_model = dask_model.fit(**fit_params)
# total number of trees scales up for ova classifier.
if task == "multiclass-classification":
model_trees = fit_trees * dask_model.n_classes_
else:
model_trees = fit_trees
# check that early stopping was not applied.
assert dask_model.booster_.num_trees() == model_trees
assert dask_model.best_iteration_ == 0
# checks that evals_result_ and best_score_ contain expected data and eval_set names.
evals_result = dask_model.evals_result_
best_scores = dask_model.best_score_
assert len(evals_result) == n_eval_sets
assert len(best_scores) == n_eval_sets
for eval_name in evals_result:
assert eval_name in dask_model.best_score_
if eval_names:
assert eval_name in eval_names
# check that each eval_name and metric exists for all eval sets, allowing for the
# case when a worker receives a fully-padded eval_set component which is not evaluated.
if evals_result[eval_name] != {}:
for metric in eval_metric_names:
assert metric in evals_result[eval_name]
assert metric in best_scores[eval_name]
assert len(evals_result[eval_name][metric]) == fit_trees
@pytest.mark.parametrize("task", ["binary-classification", "regression", "ranking"])
def test_eval_set_with_custom_eval_metric(task, cluster):
with Client(cluster) as client:
n_samples = 1000
n_eval_samples = int(n_samples * 0.5)
chunk_size = 10
output = "array"
X, y, w, g, dX, dy, dw, dg = _create_data(
objective=task, n_samples=n_samples, output=output, chunk_size=chunk_size
)
_, _, _, _, dX_e, dy_e, _, dg_e = _create_data(
objective=task, n_samples=n_eval_samples, output=output, chunk_size=chunk_size
)
if task == "ranking":
eval_at = (5, 6)
eval_metrics = ["ndcg", _constant_metric]
eval_metric_names = [f"ndcg@{k}" for k in eval_at] + ["constant_metric"]
elif task == "binary-classification":
eval_metrics = ["binary_error", "auc", _constant_metric]
eval_metric_names = ["binary_logloss", "binary_error", "auc", "constant_metric"]
else:
eval_metrics = ["l1", _constant_metric]
eval_metric_names = ["l2", "l1", "constant_metric"]
fit_trees = 50
params = {"random_state": 42, "n_estimators": fit_trees, "num_leaves": 2}
model_factory = task_to_dask_factory[task]
dask_model = model_factory(client=client, **params)
eval_set = [(dX_e, dy_e)]
fit_params = {"X": dX, "y": dy, "eval_set": eval_set, "eval_metric": eval_metrics}
if task == "ranking":
fit_params.update({"group": dg, "eval_group": [dg_e], "eval_at": eval_at})
dask_model = dask_model.fit(**fit_params)
eval_name = "valid_0"
evals_result = dask_model.evals_result_
assert len(evals_result) == 1
assert eval_name in evals_result
for metric in eval_metric_names:
assert metric in evals_result[eval_name]
assert len(evals_result[eval_name][metric]) == fit_trees
np.testing.assert_allclose(evals_result[eval_name]["constant_metric"], 0.708)
@pytest.mark.parametrize("task", tasks)
def test_training_works_if_client_not_provided_or_set_after_construction(task, cluster):
with Client(cluster) as client:
_, _, _, _, dX, dy, _, dg = _create_data(objective=task, output="array", group=None)
model_factory = task_to_dask_factory[task]
params = {"time_out": 5, "n_estimators": 1, "num_leaves": 2}
# should be able to use the class without specifying a client
dask_model = model_factory(**params)
assert dask_model.client is None
with pytest.raises(lgb.compat.LGBMNotFittedError, match="Cannot access property client_ before calling fit"):
dask_model.client_
dask_model.fit(dX, dy, group=dg)
assert dask_model.fitted_
assert dask_model.client is None
assert dask_model.client_ == client
preds = dask_model.predict(dX)
assert isinstance(preds, da.Array)
assert dask_model.fitted_
assert dask_model.client is None
assert dask_model.client_ == client
local_model = dask_model.to_local()
no_client_attr_msg = re.compile(
f"{repr(type(local_model).__name__)} object has no attribute '(client|client_)'"
)
with pytest.raises(AttributeError, match=no_client_attr_msg):
local_model.client
with pytest.raises(AttributeError, match=no_client_attr_msg):
local_model.client_
# should be able to set client after construction
dask_model = model_factory(**params)
dask_model.set_params(client=client)
assert dask_model.client == client
with pytest.raises(lgb.compat.LGBMNotFittedError, match="Cannot access property client_ before calling fit"):
dask_model.client_
dask_model.fit(dX, dy, group=dg)
assert dask_model.fitted_
assert dask_model.client == client
assert dask_model.client_ == client
preds = dask_model.predict(dX)
assert isinstance(preds, da.Array)
assert dask_model.fitted_
assert dask_model.client == client
assert dask_model.client_ == client
local_model = dask_model.to_local()
with pytest.raises(AttributeError, match=no_client_attr_msg):
local_model.client
with pytest.raises(AttributeError, match=no_client_attr_msg):
local_model.client_
@pytest.mark.parametrize("serializer", ["pickle", "joblib", "cloudpickle"])
@pytest.mark.parametrize("task", tasks)
@pytest.mark.parametrize("set_client", [True, False])
def test_model_and_local_version_are_picklable_whether_or_not_client_set_explicitly(
serializer, task, set_client, tmp_path, cluster, cluster2
):
with Client(cluster) as client1:
# data on cluster1
X_1, _, _, _, dX_1, dy_1, _, dg_1 = _create_data(objective=task, output="array", group=None)
with Client(cluster2) as client2:
# create identical data on cluster2
X_2, _, _, _, dX_2, dy_2, _, dg_2 = _create_data(objective=task, output="array", group=None)
model_factory = task_to_dask_factory[task]
params = {"time_out": 5, "n_estimators": 1, "num_leaves": 2}
# at this point, the result of default_client() is client2 since it was the most recently
# created. So setting client to client1 here to test that you can select a non-default client
assert default_client() == client2
if set_client:
params.update({"client": client1})
# unfitted model should survive pickling round trip, and pickling
# shouldn't have side effects on the model object
dask_model = model_factory(**params)
local_model = dask_model.to_local()
if set_client:
assert dask_model.client == client1
else:
assert dask_model.client is None
with pytest.raises(
lgb.compat.LGBMNotFittedError, match="Cannot access property client_ before calling fit"
):
dask_model.client_
assert "client" not in local_model.get_params()
assert getattr(local_model, "client", None) is None
tmp_file = tmp_path / "model-1.pkl"
pickle_obj(obj=dask_model, filepath=tmp_file, serializer=serializer)
model_from_disk = unpickle_obj(filepath=tmp_file, serializer=serializer)
local_tmp_file = tmp_path / "local-model-1.pkl"
pickle_obj(obj=local_model, filepath=local_tmp_file, serializer=serializer)
local_model_from_disk = unpickle_obj(filepath=local_tmp_file, serializer=serializer)
assert model_from_disk.client is None
if set_client:
assert dask_model.client == client1
else:
assert dask_model.client is None
with pytest.raises(
lgb.compat.LGBMNotFittedError, match="Cannot access property client_ before calling fit"
):
dask_model.client_
# client will always be None after unpickling
if set_client:
from_disk_params = model_from_disk.get_params()
from_disk_params.pop("client", None)
dask_params = dask_model.get_params()
dask_params.pop("client", None)
assert from_disk_params == dask_params
else:
assert model_from_disk.get_params() == dask_model.get_params()
assert local_model_from_disk.get_params() == local_model.get_params()
# fitted model should survive pickling round trip, and pickling
# shouldn't have side effects on the model object
if set_client:
dask_model.fit(dX_1, dy_1, group=dg_1)
else:
dask_model.fit(dX_2, dy_2, group=dg_2)
local_model = dask_model.to_local()
assert "client" not in local_model.get_params()
no_client_attr_msg = re.compile(
f"{repr(type(local_model).__name__)} object has no attribute '(client|client_)'"
)
with pytest.raises(AttributeError, match=no_client_attr_msg):
local_model.client
with pytest.raises(AttributeError, match=no_client_attr_msg):
local_model.client_
tmp_file2 = tmp_path / "model-2.pkl"
pickle_obj(obj=dask_model, filepath=tmp_file2, serializer=serializer)
fitted_model_from_disk = unpickle_obj(filepath=tmp_file2, serializer=serializer)
local_tmp_file2 = tmp_path / "local-model-2.pkl"
pickle_obj(obj=local_model, filepath=local_tmp_file2, serializer=serializer)
local_fitted_model_from_disk = unpickle_obj(filepath=local_tmp_file2, serializer=serializer)
if set_client:
assert dask_model.client == client1
assert dask_model.client_ == client1
else:
assert dask_model.client is None
assert dask_model.client_ == default_client()
assert dask_model.client_ == client2
assert isinstance(fitted_model_from_disk, model_factory)
assert fitted_model_from_disk.client is None
assert fitted_model_from_disk.client_ == default_client()
assert fitted_model_from_disk.client_ == client2
# client will always be None after unpickling
if set_client:
from_disk_params = fitted_model_from_disk.get_params()
from_disk_params.pop("client", None)
dask_params = dask_model.get_params()
dask_params.pop("client", None)
assert from_disk_params == dask_params
else:
assert fitted_model_from_disk.get_params() == dask_model.get_params()
assert local_fitted_model_from_disk.get_params() == local_model.get_params()
if set_client:
preds_orig = dask_model.predict(dX_1).compute()
preds_loaded_model = fitted_model_from_disk.predict(dX_1).compute()
preds_orig_local = local_model.predict(X_1)
preds_loaded_model_local = local_fitted_model_from_disk.predict(X_1)
else:
preds_orig = dask_model.predict(dX_2).compute()
preds_loaded_model = fitted_model_from_disk.predict(dX_2).compute()
preds_orig_local = local_model.predict(X_2)
preds_loaded_model_local = local_fitted_model_from_disk.predict(X_2)
assert_eq(preds_orig, preds_loaded_model)
assert_eq(preds_orig_local, preds_loaded_model_local)
def test_warns_and_continues_on_unrecognized_tree_learner(cluster):
with Client(cluster) as client:
X = da.random.random((1e3, 10))
y = da.random.random((1e3, 1))
dask_regressor = lgb.DaskLGBMRegressor(
client=client, time_out=5, tree_learner="some-nonsense-value", n_estimators=1, num_leaves=2
)
with pytest.warns(UserWarning, match="Parameter tree_learner set to some-nonsense-value"):
dask_regressor = dask_regressor.fit(X, y)
assert dask_regressor.fitted_
@pytest.mark.parametrize("tree_learner", ["data_parallel", "voting_parallel"])
def test_training_respects_tree_learner_aliases(tree_learner, cluster):
with Client(cluster) as client:
task = "regression"
_, _, _, _, dX, dy, dw, dg = _create_data(objective=task, output="array")
dask_factory = task_to_dask_factory[task]
dask_model = dask_factory(client=client, tree_learner=tree_learner, time_out=5, n_estimators=10, num_leaves=15)
dask_model.fit(dX, dy, sample_weight=dw, group=dg)
assert dask_model.fitted_
assert dask_model.get_params()["tree_learner"] == tree_learner
def test_error_on_feature_parallel_tree_learner(cluster):
with Client(cluster) as client:
X = da.random.random((100, 10), chunks=(50, 10))
y = da.random.random(100, chunks=50)
X, y = client.persist([X, y])
_ = wait([X, y])
client.rebalance()
dask_regressor = lgb.DaskLGBMRegressor(
client=client, time_out=5, tree_learner="feature_parallel", n_estimators=1, num_leaves=2
)
with pytest.raises(lgb.basic.LightGBMError, match="Do not support feature parallel in c api"):
dask_regressor = dask_regressor.fit(X, y)
def test_errors(cluster):
with Client(cluster) as client:
def f(part):
raise Exception("foo")
df = dd.demo.make_timeseries()
df = df.map_partitions(f, meta=df._meta)
with pytest.raises(Exception) as info: # noqa: PT011, PT012 # error message needs to be coerced to a string
lgb.dask._train(client=client, data=df, label=df.x, params={}, model_factory=lgb.LGBMClassifier)
assert "foo" in str(info.value)
@pytest.mark.parametrize("task", tasks)
@pytest.mark.parametrize("output", data_output)
def test_training_succeeds_even_if_some_workers_do_not_have_any_data(task, output, cluster_three_workers):
if task == "ranking" and output == "scipy_csr_matrix":
pytest.skip("LGBMRanker is not currently tested on sparse matrices")
with Client(cluster_three_workers) as client:
_, y, _, _, dX, dy, dw, dg = _create_data(
objective=task,
output=output,
group=None,
n_samples=1_000,
chunk_size=200,
)
dask_model_factory = task_to_dask_factory[task]
workers = list(client.scheduler_info()["workers"].keys())
assert len(workers) == 3
first_two_workers = workers[:2]
dX = client.persist(dX, workers=first_two_workers)
dy = client.persist(dy, workers=first_two_workers)
dw = client.persist(dw, workers=first_two_workers)
wait([dX, dy, dw])
workers_with_data = set()
for coll in (dX, dy, dw):
for with_data in client.who_has(coll).values():
workers_with_data.update(with_data)
assert workers[2] not in with_data
assert len(workers_with_data) == 2
params = {
"time_out": 5,
"random_state": 42,
"num_leaves": 10,
"n_estimators": 20,
}
dask_model = dask_model_factory(tree="data", client=client, **params)
dask_model.fit(dX, dy, group=dg, sample_weight=dw)
dask_preds = dask_model.predict(dX).compute()
if task == "regression":
score = r2_score(y, dask_preds)
elif task.endswith("classification"):
score = accuracy_score(y, dask_preds)
else:
score = spearmanr(dask_preds, y).correlation
assert score > 0.9
@pytest.mark.parametrize("task", tasks)
def test_network_params_not_required_but_respected_if_given(task, listen_port, cluster):
with Client(cluster) as client:
_, _, _, _, dX, dy, _, dg = _create_data(objective=task, output="array", chunk_size=10, group=None)
dask_model_factory = task_to_dask_factory[task]
# rebalance data to be sure that each worker has a piece of the data
client.rebalance()
# model 1 - no network parameters given
dask_model1 = dask_model_factory(
n_estimators=5,
num_leaves=5,
)
dask_model1.fit(dX, dy, group=dg)
assert dask_model1.fitted_
params = dask_model1.get_params()
assert "local_listen_port" not in params
assert "machines" not in params
# model 2 - machines given
workers = list(client.scheduler_info()["workers"])
workers_hostname = _get_workers_hostname(cluster)
remote_sockets, open_ports = lgb.dask._assign_open_ports_to_workers(
client=client,
workers=workers,
)
for s in remote_sockets.values():
s.release()
dask_model2 = dask_model_factory(
n_estimators=5,
num_leaves=5,
machines=",".join([f"{workers_hostname}:{port}" for port in open_ports.values()]),
)
dask_model2.fit(dX, dy, group=dg)
assert dask_model2.fitted_
params = dask_model2.get_params()
assert "local_listen_port" not in params
assert "machines" in params
# model 3 - local_listen_port given
# training should fail because LightGBM will try to use the same
# port for multiple worker processes on the same machine
dask_model3 = dask_model_factory(n_estimators=5, num_leaves=5, local_listen_port=listen_port)
error_msg = "has multiple Dask worker processes running on it"
with pytest.raises(lgb.basic.LightGBMError, match=error_msg):
dask_model3.fit(dX, dy, group=dg)
@pytest.mark.parametrize("task", tasks)
def test_machines_should_be_used_if_provided(task, cluster):
pytest.skip("skipping due to timeout issues discussed in https://github.com/lightgbm-org/LightGBM/issues/5390")
with Client(cluster) as client:
_, _, _, _, dX, dy, _, dg = _create_data(objective=task, output="array", chunk_size=10, group=None)
dask_model_factory = task_to_dask_factory[task]
# rebalance data to be sure that each worker has a piece of the data
client.rebalance()
n_workers = len(client.scheduler_info()["workers"])
assert n_workers > 1
workers_hostname = _get_workers_hostname(cluster)
open_ports = lgb.dask._find_n_open_ports(n_workers)
dask_model = dask_model_factory(
n_estimators=5,
num_leaves=5,
machines=",".join([f"{workers_hostname}:{port}" for port in open_ports]),
)
# test that "machines" is actually respected by creating a socket that uses
# one of the ports mentioned in "machines"
error_msg = f"Binding port {open_ports[0]} failed"
with pytest.raises(lgb.basic.LightGBMError, match=error_msg): # noqa: PT012
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind((workers_hostname, open_ports[0]))
dask_model.fit(dX, dy, group=dg)
# The above error leaves a worker waiting
client.restart()
# an informative error should be raised if "machines" has duplicates
one_open_port = lgb.dask._find_n_open_ports(1)
dask_model.set_params(machines=",".join([f"127.0.0.1:{one_open_port}" for _ in range(n_workers)]))
with pytest.raises(ValueError, match="Found duplicates in 'machines'"):
dask_model.fit(dX, dy, group=dg)
@pytest.mark.parametrize(
("dask_est", "sklearn_est"),
[
(lgb.DaskLGBMClassifier, lgb.LGBMClassifier),
(lgb.DaskLGBMRegressor, lgb.LGBMRegressor),
(lgb.DaskLGBMRanker, lgb.LGBMRanker),
],
)
def test_dask_classes_and_sklearn_equivalents_have_identical_constructors_except_client_arg(dask_est, sklearn_est):
dask_spec = inspect.getfullargspec(dask_est)
sklearn_spec = inspect.getfullargspec(sklearn_est)
# should not allow for any varargs
assert dask_spec.varargs == sklearn_spec.varargs
assert dask_spec.varargs is None
# the only varkw should be **kwargs,
# for pass-through to parent classes' __init__()
assert dask_spec.varkw == sklearn_spec.varkw
assert dask_spec.varkw == "kwargs"
# "client" should be the only different, and the final argument
assert dask_spec.kwonlyargs == [*sklearn_spec.kwonlyargs, "client"]
# default values for all constructor arguments should be identical
#
# NOTE: if LGBMClassifier / LGBMRanker / LGBMRegressor ever override
# any of LGBMModel's constructor arguments, this will need to be updated
assert dask_spec.kwonlydefaults == {**sklearn_spec.kwonlydefaults, "client": None}
# only positional argument should be 'self'
assert dask_spec.args == sklearn_spec.args
assert dask_spec.args == ["self"]
assert dask_spec.defaults is None
# get_params() should be identical, except for "client"
assert dask_est().get_params() == {**sklearn_est().get_params(), "client": None}
@pytest.mark.parametrize(
"methods",
[
(lgb.DaskLGBMClassifier.fit, lgb.LGBMClassifier.fit),
(lgb.DaskLGBMClassifier.predict, lgb.LGBMClassifier.predict),
(lgb.DaskLGBMClassifier.predict_proba, lgb.LGBMClassifier.predict_proba),
(lgb.DaskLGBMRegressor.fit, lgb.LGBMRegressor.fit),
(lgb.DaskLGBMRegressor.predict, lgb.LGBMRegressor.predict),
(lgb.DaskLGBMRanker.fit, lgb.LGBMRanker.fit),
(lgb.DaskLGBMRanker.predict, lgb.LGBMRanker.predict),
],
)
def test_dask_methods_and_sklearn_equivalents_have_similar_signatures(methods):
dask_spec = inspect.getfullargspec(methods[0])
sklearn_spec = inspect.getfullargspec(methods[1])
dask_params = inspect.signature(methods[0]).parameters
sklearn_params = inspect.signature(methods[1]).parameters
assert dask_spec.args == sklearn_spec.args[: len(dask_spec.args)]
assert dask_spec.varargs == sklearn_spec.varargs
if sklearn_spec.varkw:
assert dask_spec.varkw == sklearn_spec.varkw[: len(dask_spec.varkw)]
assert dask_spec.kwonlyargs == sklearn_spec.kwonlyargs
assert dask_spec.kwonlydefaults == sklearn_spec.kwonlydefaults
for param in dask_spec.args:
error_msg = f"param '{param}' has different default values in the methods"
assert dask_params[param].default == sklearn_params[param].default, error_msg
@pytest.mark.parametrize("task", tasks)
def test_training_succeeds_when_data_is_dataframe_and_label_is_column_array(task, cluster):
with Client(cluster):
_, _, _, _, dX, dy, dw, dg = _create_data(objective=task, output="dataframe", group=None)
model_factory = task_to_dask_factory[task]
dy = dy.to_dask_array(lengths=True)
dy_col_array = dy.reshape(-1, 1)
assert len(dy_col_array.shape) == 2
assert dy_col_array.shape[1] == 1
params = {"n_estimators": 1, "num_leaves": 3, "random_state": 0, "time_out": 5}
model = model_factory(**params)
model.fit(dX, dy_col_array, sample_weight=dw, group=dg)
assert model.fitted_
@pytest.mark.parametrize("task", tasks)
@pytest.mark.parametrize("output", data_output)
def test_init_score(task, output, cluster, rng):
if task == "ranking" and output == "scipy_csr_matrix":
pytest.skip("LGBMRanker is not currently tested on sparse matrices")
with Client(cluster) as client:
_, _, _, _, dX, dy, dw, dg = _create_data(objective=task, output=output, group=None)
model_factory = task_to_dask_factory[task]
params = {
"n_estimators": 1,
"num_leaves": 2,
"time_out": 5,
"seed": 708,
"deterministic": True,
"force_row_wise": True,
"num_thread": 1,
}
num_classes = 1
if task == "multiclass-classification":
num_classes = 3
if output.startswith("dataframe"):
init_scores = dy.map_partitions(lambda x: pd.DataFrame(rng.uniform(size=(x.size, num_classes))))
else:
init_scores = dy.map_blocks(lambda x: rng.uniform(size=(x.size, num_classes)))
model = model_factory(client=client, **params)
model.fit(dX, dy, sample_weight=dw, group=dg)
pred = model.predict(dX, raw_score=True)
model_init_score = model_factory(client=client, **params)
model_init_score.fit(dX, dy, sample_weight=dw, init_score=init_scores, group=dg)
pred_init_score = model_init_score.predict(dX, raw_score=True)
# check if init score changes predictions
with pytest.raises(AssertionError): # noqa: PT011
assert_eq(pred, pred_init_score)
def sklearn_checks_to_run():
check_names = ["check_estimator_get_tags_default_keys", "check_get_params_invariance", "check_set_params"]
for check_name in check_names:
check_func = getattr(sklearn_checks, check_name, None)
if check_func:
yield check_func
def _tested_estimators():
for Estimator in [lgb.DaskLGBMClassifier, lgb.DaskLGBMRegressor]:
yield Estimator()
@pytest.mark.parametrize("estimator", _tested_estimators())
@pytest.mark.parametrize("check", sklearn_checks_to_run())
def test_sklearn_integration(estimator, check, cluster):
with Client(cluster):
estimator.set_params(local_listen_port=18000, time_out=5)
name = type(estimator).__name__
check(name, estimator)
# this test is separate because it takes a not-yet-constructed estimator
@pytest.mark.parametrize("estimator", list(_tested_estimators()))
def test_parameters_default_constructible(estimator):
name = estimator.__class__.__name__
Estimator = estimator
sklearn_checks.check_parameters_default_constructible(name, Estimator)
@pytest.mark.parametrize("task", tasks)
@pytest.mark.parametrize("output", data_output)
def test_predict_with_raw_score(task, output, cluster):
if task == "ranking" and output == "scipy_csr_matrix":
pytest.skip("LGBMRanker is not currently tested on sparse matrices")
with Client(cluster) as client:
_, _, _, _, dX, dy, _, dg = _create_data(objective=task, output=output, group=None)
model_factory = task_to_dask_factory[task]
params = {"client": client, "n_estimators": 1, "num_leaves": 2, "time_out": 5, "min_sum_hessian": 0}
model = model_factory(**params)
model.fit(dX, dy, group=dg)
raw_predictions = model.predict(dX, raw_score=True).compute()
trees_df = model.booster_.trees_to_dataframe()
leaves_df = trees_df[trees_df.node_depth == 2]
if task == "multiclass-classification":
for i in range(model.n_classes_):
class_df = leaves_df[leaves_df.tree_index == i]
assert set(raw_predictions[:, i]) == set(class_df["value"])
else:
assert set(raw_predictions) == set(leaves_df["value"])
if task.endswith("classification"):
pred_proba_raw = model.predict_proba(dX, raw_score=True).compute()
assert_eq(raw_predictions, pred_proba_raw)
@pytest.mark.parametrize("output", data_output)
@pytest.mark.parametrize("task", tasks)
def test_predict_returns_expected_dtypes(task, output, cluster):
if task == "ranking" and output == "scipy_csr_matrix":
pytest.skip("LGBMRanker is not currently tested on sparse matrices")
with Client(cluster) as client:
_, _, _, _, dX, dy, _, dg = _create_data(objective=task, output=output, group=None)
model_factory = task_to_dask_factory[task]
params = {
"client": client,
"n_estimators": 1,
"num_leaves": 2,
"time_out": 5,
"verbose": -1,
}
model = model_factory(**params)
model.fit(dX, dy, group=dg)
# use a small sub-sample (to keep the tests fast)
if output.startswith("dataframe"):
dX_sample = dX.sample(frac=0.001)
else:
dX_sample = dX[:1,]
dX_sample.persist()
# default predictions:
#
# * classification: int64
# * ranking: float64
# * regression: float64
#
preds = model.predict(dX_sample).compute()
if task.endswith("classification"):
# preds go through LabelEncoder.inverse_transform() and have the same
# dtype as model.classes_ (expected to be an integer type, but exact size
# varies across numpy versions and operating systems)
assert preds.dtype == model.classes_.dtype
assert preds.dtype in (np.int32, np.int64)
else:
assert preds.dtype == np.float64
# raw predictions: always float64
preds_raw = model.predict(dX_sample, raw_score=True).compute()
assert preds_raw.dtype == np.float64
# pred_contrib: always float64
if output.startswith("scipy"):
preds_contrib = [arr.compute() for arr in model.predict(dX_sample, pred_contrib=True)]
assert all(arr.dtype == np.float64 for arr in preds_contrib)
else:
preds_contrib = model.predict(dX_sample, pred_contrib=True).compute()
assert preds_contrib.dtype == np.float64
# pred_leavs: always int32
preds_leaves = model.predict(dX_sample, pred_leaf=True).compute()
assert preds_leaves.dtype == np.int32
@pytest.mark.parametrize("output", data_output)
@pytest.mark.parametrize("use_init_score", [False, True])
def test_predict_stump(output, use_init_score, cluster, rng):
with Client(cluster) as client:
_, _, _, _, dX, dy, _, _ = _create_data(objective="binary-classification", n_samples=1_000, output=output)
params = {"objective": "binary", "n_estimators": 5, "min_data_in_leaf": 1_000}
if not use_init_score:
init_scores = None
elif output.startswith("dataframe"):
init_scores = dy.map_partitions(lambda x: pd.DataFrame(rng.uniform(size=x.size)))
else:
init_scores = dy.map_blocks(lambda x: rng.uniform(size=x.size))
model = lgb.DaskLGBMClassifier(client=client, **params)
model.fit(dX, dy, init_score=init_scores)
preds_1 = model.predict(dX, raw_score=True, num_iteration=1).compute()
preds_all = model.predict(dX, raw_score=True).compute()
if use_init_score:
# if init_score was provided, a model of stumps should predict all 0s
all_zeroes = np.full_like(preds_1, fill_value=0.0)
assert_eq(preds_1, all_zeroes)
assert_eq(preds_all, all_zeroes)
else:
# if init_score was not provided, prediction for a model of stumps should be
# the "average" of the labels
y_avg = np.log(dy.mean() / (1.0 - dy.mean()))
assert_eq(preds_1, np.full_like(preds_1, fill_value=y_avg))
assert_eq(preds_all, np.full_like(preds_all, fill_value=y_avg))
def test_distributed_quantized_training(tmp_path, cluster):
with Client(cluster) as client:
X, y, w, _, dX, dy, dw, _ = _create_data(objective="regression", output="array")
np.savetxt(tmp_path / "data_dask.csv", np.hstack([np.array([y]).T, X]), fmt="%f,%f,%f,%f,%f")
params = {
"boosting_type": "gbdt",
"n_estimators": 50,
"num_leaves": 31,
"use_quantized_grad": True,
"num_grad_quant_bins": 30,
"quant_train_renew_leaf": True,
"verbose": -1,
}
quant_dask_classifier = lgb.DaskLGBMRegressor(client=client, time_out=5, **params)
quant_dask_classifier = quant_dask_classifier.fit(dX, dy, sample_weight=dw)
quant_p1 = quant_dask_classifier.predict(dX)
quant_rmse = np.sqrt(np.mean((quant_p1.compute() - y) ** 2))
params["use_quantized_grad"] = False
dask_classifier = lgb.DaskLGBMRegressor(client=client, time_out=5, **params)
dask_classifier = dask_classifier.fit(dX, dy, sample_weight=dw)
p1 = dask_classifier.predict(dX)
rmse = np.sqrt(np.mean((p1.compute() - y) ** 2))
assert quant_rmse < rmse + 7.0