diff --git a/docs/source/cuml-accel/limitations.rst b/docs/source/cuml-accel/limitations.rst index 4811274f30..0faff1b84d 100644 --- a/docs/source/cuml-accel/limitations.rst +++ b/docs/source/cuml-accel/limitations.rst @@ -269,7 +269,6 @@ ElasticNet - If ``positive=True``. - If ``warm_start=True``. - If ``precompute`` is not ``False``. -- If ``X`` is sparse. Additionally, the following fitted attributes are currently not computed: @@ -290,7 +289,6 @@ Lasso - If ``positive=True``. - If ``warm_start=True``. - If ``precompute`` is not ``False``. -- If ``X`` is sparse. Additionally, the following fitted attributes are currently not computed: diff --git a/python/cuml/cuml/accel/_overrides/sklearn/linear_model.py b/python/cuml/cuml/accel/_overrides/sklearn/linear_model.py index b80ba0d215..0a6d43f3b8 100644 --- a/python/cuml/cuml/accel/_overrides/sklearn/linear_model.py +++ b/python/cuml/cuml/accel/_overrides/sklearn/linear_model.py @@ -10,6 +10,7 @@ from cuml.accel.estimator_proxy import ProxyBase from cuml.common.sparse_utils import is_sparse from cuml.internals.array import CumlArray +from cuml.internals.input_utils import input_to_cuml_array from cuml.internals.interop import UnsupportedOnGPU from cuml.internals.outputs import using_output_type @@ -34,15 +35,6 @@ class LogisticRegression(ProxyBase): _gpu_class = cuml.linear_model.LogisticRegression -class ElasticNet(ProxyBase): - _gpu_class = cuml.linear_model.ElasticNet - _not_implemented_attributes = frozenset(("dual_gap_",)) - - def _gpu_fit(self, X, y, sample_weight=None, check_input=True): - # Fixes signature mismatch with cuml.ElasticNet. check_input can be ignored. - return self._gpu.fit(X, y, sample_weight=sample_weight) - - class Ridge(ProxyBase): _gpu_class = cuml.linear_model.Ridge @@ -74,10 +66,27 @@ def _gpu_fit(self, X, y, sample_weight=None): return self +class ElasticNet(ProxyBase): + _gpu_class = cuml.linear_model.ElasticNet + _not_implemented_attributes = frozenset(("dual_gap_",)) + + def _gpu_fit(self, X, y, sample_weight=None, check_input=True): + # check_input is ignored, only here to fix signature mismatch with sklearn + + y = input_to_cuml_array(y, convert_to_mem_type=False)[0] + if len(y.shape) > 1 and y.shape[1] > 1: + raise UnsupportedOnGPU("Multi-output targets are not supported") + return self._gpu.fit(X, y, sample_weight=sample_weight) + + class Lasso(ProxyBase): _gpu_class = cuml.linear_model.Lasso _not_implemented_attributes = frozenset(("dual_gap_",)) def _gpu_fit(self, X, y, sample_weight=None, check_input=True): - # Fixes signature mismatch with cuml.Lasso. check_input can be ignored. + # check_input is ignored, only here to fix signature mismatch with sklearn + + y = input_to_cuml_array(y, convert_to_mem_type=False)[0] + if len(y.shape) > 1 and y.shape[1] > 1: + raise UnsupportedOnGPU("Multi-output targets are not supported") return self._gpu.fit(X, y, sample_weight=sample_weight) diff --git a/python/cuml/cuml/linear_model/elastic_net.py b/python/cuml/cuml/linear_model/elastic_net.py index 9618ba3a5c..baee809668 100644 --- a/python/cuml/cuml/linear_model/elastic_net.py +++ b/python/cuml/cuml/linear_model/elastic_net.py @@ -2,8 +2,11 @@ # SPDX-FileCopyrightText: Copyright (c) 2019-2026, NVIDIA CORPORATION. # SPDX-License-Identifier: Apache-2.0 # +import cupyx.scipy.sparse + from cuml.common.array_descriptor import CumlArrayDescriptor from cuml.common.doc_utils import generate_docstring +from cuml.common.sparse_utils import is_sparse from cuml.internals.array import CumlArray from cuml.internals.base import Base from cuml.internals.interop import ( @@ -12,7 +15,11 @@ to_cpu, to_gpu, ) -from cuml.internals.mixins import FMajorInputTagMixin, RegressorMixin +from cuml.internals.mixins import ( + FMajorInputTagMixin, + RegressorMixin, + SparseInputTagMixin, +) from cuml.internals.outputs import reflect from cuml.linear_model.base import LinearPredictMixin from cuml.solvers.cd import fit_coordinate_descent @@ -20,7 +27,12 @@ class ElasticNet( - Base, InteropMixin, LinearPredictMixin, RegressorMixin, FMajorInputTagMixin + Base, + InteropMixin, + LinearPredictMixin, + RegressorMixin, + SparseInputTagMixin, + FMajorInputTagMixin, ): """ Linear regression with combined L1 and L2 priors as regularizer. @@ -48,11 +60,12 @@ class ElasticNet( The tolerance for the optimization: if the updates are smaller than tol, the optimization code checks the dual gap for optimality and continues until it is smaller than tol. - solver : {'cd', 'qn'}, default='cd' - Choose an algorithm: + solver : {'auto', 'cd', 'qn'}, default='auto' + The solver to use. - * 'cd' - coordinate descent - * 'qn' - quasi-newton + - 'auto': uses 'cd' for dense inputs, and 'qn' for sparse inputs + - 'cd': uses coordinate descent. Only supports dense inputs. + - 'qn': uses quasi-newton methods. Supports sparse and dense inputs. You may find the alternative 'qn' algorithm is faster when the number of features is sufficiently large but the sample size is small. @@ -76,6 +89,8 @@ class ElasticNet( ---------- coef_ : array, shape (n_features) The estimated coefficients for the linear regression model. + sparse_coef_ : sparse matrix, shape (n_targets, n_features) + Sparse matrix representation of `coef_`. intercept_ : float The independent term, will be 0 if `fit_intercept` is False. n_iter_ : int @@ -192,7 +207,7 @@ def __init__( fit_intercept=True, max_iter=1000, tol=1e-3, - solver="cd", + solver="auto", selection="cyclic", output_type=None, verbose=False, @@ -207,6 +222,12 @@ def __init__( self.solver = solver self.selection = selection + @property + @reflect + def sparse_coef_(self): + """Sparse representation of the fitted `coef_`.""" + return cupyx.scipy.sparse.csr_matrix(self.coef_.to_output("cupy")) + @generate_docstring() @reflect(reset=True) def fit( @@ -225,7 +246,11 @@ def fit( f"Expected 0.0 <= l1_ratio <= 1.0, got {self.l1_ratio}" ) - if self.solver == "qn": + solver = self.solver + if solver == "auto": + solver = "qn" if is_sparse(X) else "cd" + + if solver == "qn": coef, intercept, n_iter, _ = fit_qn( X, y, @@ -242,7 +267,12 @@ def fit( ) coef = CumlArray(data=coef.to_output("cupy").flatten()) intercept = intercept.item() - elif self.solver == "cd": + elif solver == "cd": + if is_sparse(X): + raise ValueError( + "solver='cd' doesn't support sparse inputs, please use " + "solver='auto' or solver='qn' instead" + ) coef, intercept, n_iter = fit_coordinate_descent( X, y, @@ -256,7 +286,7 @@ def fit( tol=self.tol, ) else: - raise ValueError(f"solver {self.solver} is not supported") + raise ValueError(f"solver={solver!r} is not supported") self.coef_ = coef self.intercept_ = intercept diff --git a/python/cuml/cuml/linear_model/lasso.py b/python/cuml/cuml/linear_model/lasso.py index 9c80630c98..bbadb2f684 100644 --- a/python/cuml/cuml/linear_model/lasso.py +++ b/python/cuml/cuml/linear_model/lasso.py @@ -30,14 +30,15 @@ class Lasso(ElasticNet): The tolerance for the optimization: if the updates are smaller than tol, the optimization code checks the dual gap for optimality and continues until it is smaller than tol. - solver : {'cd', 'qn'} (default='cd') - Choose an algorithm: + solver : {'auto', 'cd', 'qn'}, default='auto' + The solver to use. - * 'cd' - coordinate descent - * 'qn' - quasi-newton + - 'auto': uses 'cd' for dense inputs, and 'qn' for sparse inputs + - 'cd': uses coordinate descent. Only supports dense inputs. + - 'qn': uses quasi-newton methods. Supports sparse and dense inputs. You may find the alternative 'qn' algorithm is faster when the number - of features is sufficiently large, but the sample size is small. + of features is sufficiently large but the sample size is small. selection : {'cyclic', 'random'} (default='cyclic') If set to 'random', a random coefficient is updated every iteration rather than looping over features sequentially by default. @@ -57,6 +58,8 @@ class Lasso(ElasticNet): ---------- coef_ : array, shape (n_features) The estimated coefficients for the linear regression model. + sparse_coef_ : sparse matrix, shape (n_targets, n_features) + Sparse matrix representation of `coef_`. intercept_ : array The independent term. If `fit_intercept` is False, will be 0. n_iter_ : int @@ -119,7 +122,7 @@ def __init__( fit_intercept=True, max_iter=1000, tol=1e-3, - solver="cd", + solver="auto", selection="cyclic", output_type=None, verbose=False, diff --git a/python/cuml/cuml_accel_tests/upstream/scikit-learn/xfail-list.yaml b/python/cuml/cuml_accel_tests/upstream/scikit-learn/xfail-list.yaml index 611d5afd14..8d1edcf3a3 100644 --- a/python/cuml/cuml_accel_tests/upstream/scikit-learn/xfail-list.yaml +++ b/python/cuml/cuml_accel_tests/upstream/scikit-learn/xfail-list.yaml @@ -33,10 +33,16 @@ tests: - "sklearn.linear_model.tests.test_coordinate_descent::test_enet_coordinate_descent[Lasso-1-kwargs1]" - "sklearn.linear_model.tests.test_coordinate_descent::test_warm_start_convergence" + - "sklearn.linear_model.tests.test_sparse_coordinate_descent::test_sparse_dense_equality[csc_array-False-24-6-False-Lasso]" + - "sklearn.linear_model.tests.test_sparse_coordinate_descent::test_sparse_dense_equality[csc_array-False-24-6-True-Lasso]" - "sklearn.linear_model.tests.test_sparse_coordinate_descent::test_sparse_dense_equality[csc_array-False-6-24-False-ElasticNet]" - "sklearn.linear_model.tests.test_sparse_coordinate_descent::test_sparse_dense_equality[csc_array-False-6-24-True-ElasticNet]" + - "sklearn.linear_model.tests.test_sparse_coordinate_descent::test_sparse_dense_equality[csc_matrix-False-24-6-False-Lasso]" + - "sklearn.linear_model.tests.test_sparse_coordinate_descent::test_sparse_dense_equality[csc_matrix-False-24-6-True-Lasso]" - "sklearn.linear_model.tests.test_sparse_coordinate_descent::test_sparse_dense_equality[csc_matrix-False-6-24-False-ElasticNet]" - "sklearn.linear_model.tests.test_sparse_coordinate_descent::test_sparse_dense_equality[csc_matrix-False-6-24-True-ElasticNet]" + - "sklearn.linear_model.tests.test_sparse_coordinate_descent::test_sparse_enet_coordinate_descent[csc_array]" + - "sklearn.linear_model.tests.test_sparse_coordinate_descent::test_sparse_enet_coordinate_descent[csc_matrix]" - "sklearn.manifold.tests.test_t_sne::test_pca_initialization_not_compatible_with_sparse_input[csr_array]" - "sklearn.manifold.tests.test_t_sne::test_pca_initialization_not_compatible_with_sparse_input[csr_matrix]" - reason: Test should fail with cuml.accel @@ -235,7 +241,6 @@ - "sklearn.linear_model.tests.test_base::test_linear_regression" - "sklearn.linear_model.tests.test_base::test_linear_regression_pd_sparse_dataframe_warning" - "sklearn.linear_model.tests.test_common::test_balance_property[42-True-LogisticRegression]" - - "sklearn.linear_model.tests.test_coordinate_descent::test_check_input_false" - "sklearn.linear_model.tests.test_coordinate_descent::test_elasticnet_precompute_gram_weighted_samples" - "sklearn.linear_model.tests.test_coordinate_descent::test_enet_copy_X_False_check_input_False" - "sklearn.linear_model.tests.test_coordinate_descent::test_enet_float_precision" @@ -243,6 +248,10 @@ - "sklearn.linear_model.tests.test_coordinate_descent::test_enet_nonfinite_params" - "sklearn.linear_model.tests.test_coordinate_descent::test_enet_sample_weight_consistency[42-None-False-0.01-False]" - "sklearn.linear_model.tests.test_coordinate_descent::test_enet_sample_weight_consistency[42-None-False-0.01-True]" + - "sklearn.linear_model.tests.test_coordinate_descent::test_enet_sample_weight_consistency[42-csr_array-False-0.01-False]" + - "sklearn.linear_model.tests.test_coordinate_descent::test_enet_sample_weight_consistency[42-csr_array-False-0.01-True]" + - "sklearn.linear_model.tests.test_coordinate_descent::test_enet_sample_weight_consistency[42-csr_matrix-False-0.01-False]" + - "sklearn.linear_model.tests.test_coordinate_descent::test_enet_sample_weight_consistency[42-csr_matrix-False-0.01-True]" - "sklearn.linear_model.tests.test_coordinate_descent::test_enet_toy" - "sklearn.linear_model.tests.test_coordinate_descent::test_lassoCV_does_not_set_precompute[False-False]" - "sklearn.linear_model.tests.test_coordinate_descent::test_lassoCV_does_not_set_precompute[auto-False]" @@ -251,6 +260,8 @@ - "sklearn.linear_model.tests.test_coordinate_descent::test_lasso_readonly_data" - "sklearn.linear_model.tests.test_coordinate_descent::test_lasso_toy" - "sklearn.linear_model.tests.test_coordinate_descent::test_lasso_zero" + - "sklearn.linear_model.tests.test_coordinate_descent::test_sparse_input_convergence_warning[csr_array]" + - "sklearn.linear_model.tests.test_coordinate_descent::test_sparse_input_convergence_warning[csr_matrix]" - "sklearn.linear_model.tests.test_coordinate_descent::test_warm_start_convergence_with_regularizer_decrement" - "sklearn.linear_model.tests.test_ransac::test_perfect_horizontal_line" - "sklearn.linear_model.tests.test_ransac::test_ransac_exceed_max_skips" @@ -273,16 +284,36 @@ - "sklearn.linear_model.tests.test_ridge::test_ridge_sample_weight_consistency[42-saga-wide-csr_matrix-False]" - "sklearn.linear_model.tests.test_ridge::test_ridgecv_sample_weight" - "sklearn.linear_model.tests.test_sag::test_step_size_alpha_error" - - "sklearn.linear_model.tests.test_sparse_coordinate_descent::test_same_multiple_output_sparse_dense[coo_array]" - - "sklearn.linear_model.tests.test_sparse_coordinate_descent::test_same_multiple_output_sparse_dense[coo_matrix]" + - "sklearn.linear_model.tests.test_sparse_coordinate_descent::test_enet_multitarget[csc_array]" + - "sklearn.linear_model.tests.test_sparse_coordinate_descent::test_enet_multitarget[csc_matrix]" + - "sklearn.linear_model.tests.test_sparse_coordinate_descent::test_enet_toy_explicit_sparse_input[lil_array]" + - "sklearn.linear_model.tests.test_sparse_coordinate_descent::test_enet_toy_explicit_sparse_input[lil_matrix]" + - "sklearn.linear_model.tests.test_sparse_coordinate_descent::test_enet_toy_list_input[csc_array-False]" + - "sklearn.linear_model.tests.test_sparse_coordinate_descent::test_enet_toy_list_input[csc_array-True]" + - "sklearn.linear_model.tests.test_sparse_coordinate_descent::test_enet_toy_list_input[csc_matrix-False]" + - "sklearn.linear_model.tests.test_sparse_coordinate_descent::test_enet_toy_list_input[csc_matrix-True]" + - "sklearn.linear_model.tests.test_sparse_coordinate_descent::test_lasso_zero[csc_array]" + - "sklearn.linear_model.tests.test_sparse_coordinate_descent::test_lasso_zero[csc_matrix]" + - "sklearn.linear_model.tests.test_sparse_coordinate_descent::test_sparse_dense_equality[csc_array-False-6-24-False-ElasticNet]" - "sklearn.linear_model.tests.test_sparse_coordinate_descent::test_sparse_dense_equality[csc_array-False-6-24-False-Lasso]" + - "sklearn.linear_model.tests.test_sparse_coordinate_descent::test_sparse_dense_equality[csc_array-False-6-24-True-ElasticNet]" - "sklearn.linear_model.tests.test_sparse_coordinate_descent::test_sparse_dense_equality[csc_array-False-6-24-True-Lasso]" + - "sklearn.linear_model.tests.test_sparse_coordinate_descent::test_sparse_dense_equality[csc_array-True-24-6-False-ElasticNet]" + - "sklearn.linear_model.tests.test_sparse_coordinate_descent::test_sparse_dense_equality[csc_array-True-24-6-False-Lasso]" + - "sklearn.linear_model.tests.test_sparse_coordinate_descent::test_sparse_dense_equality[csc_array-True-24-6-True-ElasticNet]" + - "sklearn.linear_model.tests.test_sparse_coordinate_descent::test_sparse_dense_equality[csc_array-True-24-6-True-Lasso]" - "sklearn.linear_model.tests.test_sparse_coordinate_descent::test_sparse_dense_equality[csc_array-True-6-24-False-ElasticNet]" - "sklearn.linear_model.tests.test_sparse_coordinate_descent::test_sparse_dense_equality[csc_array-True-6-24-False-Lasso]" - "sklearn.linear_model.tests.test_sparse_coordinate_descent::test_sparse_dense_equality[csc_array-True-6-24-True-ElasticNet]" - "sklearn.linear_model.tests.test_sparse_coordinate_descent::test_sparse_dense_equality[csc_array-True-6-24-True-Lasso]" + - "sklearn.linear_model.tests.test_sparse_coordinate_descent::test_sparse_dense_equality[csc_matrix-False-6-24-False-ElasticNet]" - "sklearn.linear_model.tests.test_sparse_coordinate_descent::test_sparse_dense_equality[csc_matrix-False-6-24-False-Lasso]" + - "sklearn.linear_model.tests.test_sparse_coordinate_descent::test_sparse_dense_equality[csc_matrix-False-6-24-True-ElasticNet]" - "sklearn.linear_model.tests.test_sparse_coordinate_descent::test_sparse_dense_equality[csc_matrix-False-6-24-True-Lasso]" + - "sklearn.linear_model.tests.test_sparse_coordinate_descent::test_sparse_dense_equality[csc_matrix-True-24-6-False-ElasticNet]" + - "sklearn.linear_model.tests.test_sparse_coordinate_descent::test_sparse_dense_equality[csc_matrix-True-24-6-False-Lasso]" + - "sklearn.linear_model.tests.test_sparse_coordinate_descent::test_sparse_dense_equality[csc_matrix-True-24-6-True-ElasticNet]" + - "sklearn.linear_model.tests.test_sparse_coordinate_descent::test_sparse_dense_equality[csc_matrix-True-24-6-True-Lasso]" - "sklearn.linear_model.tests.test_sparse_coordinate_descent::test_sparse_dense_equality[csc_matrix-True-6-24-False-ElasticNet]" - "sklearn.linear_model.tests.test_sparse_coordinate_descent::test_sparse_dense_equality[csc_matrix-True-6-24-False-Lasso]" - "sklearn.linear_model.tests.test_sparse_coordinate_descent::test_sparse_dense_equality[csc_matrix-True-6-24-True-ElasticNet]" @@ -728,6 +759,8 @@ - "sklearn.neighbors.tests.test_neighbors::test_neighbor_classifiers_loocv[ball_tree-nn_model0]" - "sklearn.neighbors.tests.test_neighbors::test_neighbor_classifiers_loocv[brute-nn_model0]" - "sklearn.neighbors.tests.test_neighbors::test_neighbor_classifiers_loocv[kd_tree-nn_model0]" + - "sklearn.tests.test_common::test_estimators[ElasticNet()-check_sample_weight_equivalence_on_sparse_data]" + - "sklearn.tests.test_common::test_estimators[Lasso()-check_sample_weight_equivalence_on_sparse_data]" - "sklearn.tests.test_common::test_estimators[LogisticRegression()-check_sample_weight_equivalence_on_dense_data]" - "sklearn.tests.test_common::test_estimators[LogisticRegression()-check_sample_weight_equivalence_on_sparse_data]" - "sklearn.tests.test_common::test_estimators[Ridge()-check_non_transformer_estimators_n_iter]" @@ -1067,7 +1100,6 @@ - "sklearn.tests.test_common::test_estimators[ElasticNet()-check_dtype_object]" - "sklearn.tests.test_common::test_estimators[ElasticNet()-check_estimators_nan_inf]" - "sklearn.tests.test_common::test_estimators[ElasticNet()-check_regressor_data_not_an_array]" - - "sklearn.tests.test_common::test_estimators[ElasticNet()-check_regressor_multioutput]" - "sklearn.tests.test_common::test_estimators[ElasticNet()-check_requires_y_none]" - "sklearn.tests.test_common::test_estimators[ElasticNet()-check_sample_weights_not_an_array]" - "sklearn.tests.test_common::test_estimators[ElasticNet()-check_supervised_y_no_nan]" @@ -1093,7 +1125,6 @@ - "sklearn.tests.test_common::test_estimators[Lasso()-check_dtype_object]" - "sklearn.tests.test_common::test_estimators[Lasso()-check_estimators_nan_inf]" - "sklearn.tests.test_common::test_estimators[Lasso()-check_regressor_data_not_an_array]" - - "sklearn.tests.test_common::test_estimators[Lasso()-check_regressor_multioutput]" - "sklearn.tests.test_common::test_estimators[Lasso()-check_requires_y_none]" - "sklearn.tests.test_common::test_estimators[Lasso()-check_sample_weights_not_an_array]" - "sklearn.tests.test_common::test_estimators[Lasso()-check_supervised_y_no_nan]" @@ -1266,6 +1297,14 @@ tests: - "sklearn.tests.test_calibration::test_calibrated_classifier_cv_double_sample_weights_equivalence[False-temperature]" - "sklearn.tests.test_calibration::test_calibrated_classifier_cv_double_sample_weights_equivalence[True-temperature]" +- reason: ElasticNet sample_weight handling is a bit off + condition: scikit-learn>=1.6,<1.8 + tests: + - "sklearn.linear_model.tests.test_coordinate_descent::test_enet_alpha_max_sample_weight[sample_weight0-False-True]" +- reason: ElasticNet sample_weight handling is a bit off + condition: scikit-learn>=1.8 + tests: + - "sklearn.linear_model.tests.test_coordinate_descent::test_enet_alpha_max[sample_weight0-False-True]" - reason: Elasticnet scores attribute layout differs with cuml.accel in sklearn 1.8 condition: scikit-learn>=1.8 tests: diff --git a/python/cuml/tests/test_coordinate_descent.py b/python/cuml/tests/test_elastic_net.py similarity index 75% rename from python/cuml/tests/test_coordinate_descent.py rename to python/cuml/tests/test_elastic_net.py index 6588d5f419..9f784f8165 100644 --- a/python/cuml/tests/test_coordinate_descent.py +++ b/python/cuml/tests/test_elastic_net.py @@ -4,15 +4,61 @@ import numpy as np import pytest +import scipy.sparse +import sklearn.linear_model +from hypothesis import example, given +from hypothesis import strategies as st from sklearn.datasets import make_regression from sklearn.linear_model import ElasticNet, Lasso from sklearn.model_selection import train_test_split import cuml from cuml.metrics import r2_score +from cuml.testing.datasets import make_regression_dataset +from cuml.testing.strategies import dataset_dtypes from cuml.testing.utils import quality_param, stress_param, unit_param +@given( + datatype=dataset_dtypes(), + alpha=st.sampled_from([0.1, 1.0, 10.0]), + l1_ratio=st.sampled_from([0.1, 0.5, 0.9]), + nrows=st.integers(min_value=1000, max_value=5000), + column_info=st.sampled_from([[20, 10], [100, 50]]), +) +@example( + datatype=np.float32, + alpha=0.1, + l1_ratio=0.1, + nrows=1000, + column_info=[20, 10], +) +@example( + datatype=np.float64, + alpha=10.0, + l1_ratio=0.9, + nrows=5000, + column_info=[100, 50], +) +def test_elastic_net_solvers_eq(datatype, alpha, l1_ratio, nrows, column_info): + ncols, n_info = column_info + X_train, X_test, y_train, y_test = make_regression_dataset( + datatype, nrows, ncols, n_info + ) + + kwargs = {"alpha": alpha, "l1_ratio": l1_ratio} + cd = cuml.ElasticNet(solver="cd", **kwargs) + cd.fit(X_train, y_train) + cd_res = cd.predict(X_test) + + qn = cuml.ElasticNet(solver="qn", **kwargs) + qn.fit(X_train, y_train) + # the results of the two models should be close (even if both are bad) + assert qn.score(X_test, cd_res) > 0.90 + # coefficients of the two models should be close + assert np.corrcoef(cd.coef_, qn.coef_)[0, 1] > 0.98 + + @pytest.mark.parametrize("datatype", [np.float32, np.float64]) @pytest.mark.parametrize("alpha", [0.1, 0.001]) @pytest.mark.parametrize("algorithm", ["cyclic", "random"]) @@ -301,3 +347,54 @@ def test_max_iter_n_iter(cls, solver): model = cls(max_iter=2).fit(X, y) assert model.n_iter_ == 2 + + +def make_sparse_regression( + n_samples=1000, n_features=100, n_informative=10, seed=42, dtype="float64" +): + rng = np.random.default_rng(seed) + + w = rng.normal(size=(n_features, 1)) + w[n_informative:] = 0.0 + + X = rng.normal(size=(n_samples, n_features)) + rnd = rng.uniform(size=(n_samples, n_features)) + X[rnd > 0.5] = 0.0 + + y = np.dot(X, w).ravel().astype(dtype) + X = scipy.sparse.csr_matrix(X).astype(dtype) + return X, y + + +@pytest.mark.parametrize("dtype", ["float32", "float64"]) +@pytest.mark.parametrize("alpha", [0.2, 0.7]) +@pytest.mark.parametrize("model_name", ["ElasticNet", "Lasso"]) +def test_sparse(dtype, alpha, model_name): + X, y = make_sparse_regression(dtype=dtype) + + cu_cls = getattr(cuml.linear_model, model_name) + sk_cls = getattr(sklearn.linear_model, model_name) + + cu_model = cu_cls(alpha=alpha, tol=1e-10).fit(X, y) + sk_model = sk_cls(alpha=alpha).fit(X, y) + + np.testing.assert_allclose(cu_model.coef_, sk_model.coef_, atol=1e-3) + np.testing.assert_allclose( + cu_model.intercept_, sk_model.intercept_, atol=1e-3 + ) + + assert isinstance(cu_model.sparse_coef_, scipy.sparse.csr_matrix) + + cu_score = cu_model.score(X, y) + sk_score = sk_model.score(X, y) + assert cu_score >= sk_score - 0.1 + + +def test_solver_errors(): + X, y = make_sparse_regression() + + with pytest.raises(ValueError, match="solver='bad' is not supported"): + cuml.ElasticNet(solver="bad").fit(X, y) + + with pytest.raises(ValueError, match="solver='cd' doesn't support sparse"): + cuml.ElasticNet(solver="cd").fit(X, y) diff --git a/python/cuml/tests/test_exceptions.py b/python/cuml/tests/test_exceptions.py index f114c59cba..114cd8466b 100644 --- a/python/cuml/tests/test_exceptions.py +++ b/python/cuml/tests/test_exceptions.py @@ -11,15 +11,12 @@ from cuml.cluster import DBSCAN, HDBSCAN, KMeans from cuml.decomposition import TruncatedSVD -from cuml.linear_model import ElasticNet, Lasso # Estimators that raise TypeError when given sparse input (they don't support sparse) estimators = { "KMeans": lambda: KMeans(n_clusters=2, random_state=0), "DBSCAN": lambda: DBSCAN(eps=1.0), "TruncatedSVD": lambda: TruncatedSVD(n_components=1, random_state=0), - "ElasticNet": lambda: ElasticNet(), - "Lasso": lambda: Lasso(), "HDBSCAN": lambda: HDBSCAN(), } diff --git a/python/cuml/tests/test_linear_model.py b/python/cuml/tests/test_linear_model.py index da0dd0e0f6..01727203cc 100644 --- a/python/cuml/tests/test_linear_model.py +++ b/python/cuml/tests/test_linear_model.py @@ -14,13 +14,11 @@ from hypothesis import target from scipy.sparse import csr_matrix from sklearn.datasets import load_breast_cancer, load_digits -from sklearn.linear_model import ElasticNet as skElasticNet from sklearn.linear_model import LinearRegression as skLinearRegression from sklearn.linear_model import LogisticRegression as skLog from sklearn.model_selection import train_test_split import cuml -from cuml import ElasticNet as cuElasticNet from cuml import LinearRegression as cuLinearRegression from cuml import LogisticRegression as cuLog from cuml.testing.datasets import ( @@ -859,46 +857,6 @@ def test_logistic_regression_max_iter_n_iter(penalty): assert model.n_iter_.max() == 10 -@given( - datatype=dataset_dtypes(), - alpha=st.sampled_from([0.1, 1.0, 10.0]), - l1_ratio=st.sampled_from([0.1, 0.5, 0.9]), - nrows=st.integers(min_value=1000, max_value=5000), - column_info=st.sampled_from([[20, 10], [100, 50]]), -) -@example( - datatype=np.float32, - alpha=0.1, - l1_ratio=0.1, - nrows=1000, - column_info=[20, 10], -) -@example( - datatype=np.float64, - alpha=10.0, - l1_ratio=0.9, - nrows=5000, - column_info=[100, 50], -) -def test_elasticnet_solvers_eq(datatype, alpha, l1_ratio, nrows, column_info): - ncols, n_info = column_info - X_train, X_test, y_train, y_test = make_regression_dataset( - datatype, nrows, ncols, n_info - ) - - kwargs = {"alpha": alpha, "l1_ratio": l1_ratio} - cd = cuElasticNet(solver="cd", **kwargs) - cd.fit(X_train, y_train) - cd_res = cd.predict(X_test) - - qn = cuElasticNet(solver="qn", **kwargs) - qn.fit(X_train, y_train) - # the results of the two models should be close (even if both are bad) - assert qn.score(X_test, cd_res) > 0.90 - # coefficients of the two models should be close - assert np.corrcoef(cd.coef_, qn.coef_)[0, 1] > 0.98 - - @pytest.mark.filterwarnings("ignore:Changing solver.*:UserWarning") @given( algo=st.sampled_from(["eig", "qr", "svd", "svd-qr", "lsmr"]), @@ -1024,58 +982,3 @@ def test_linear_regression_sparse(dtype, fit_intercept, weighted, n_targets): # Check predictions are close np.testing.assert_allclose(cu_pred, sk_pred, atol=1e-2) - - -@given( - ntargets=st.integers(min_value=1, max_value=2), - datatype=dataset_dtypes(), - solver=st.sampled_from(["cd", "qn"]), - nrows=st.integers(min_value=1000, max_value=5000), - column_info=st.sampled_from([[20, 10], [100, 50]]), -) -@example( - ntargets=1, - datatype=np.float32, - solver="cd", - nrows=1000, - column_info=[20, 10], -) -@example( - ntargets=2, - datatype=np.float64, - solver="qn", - nrows=5000, - column_info=[100, 50], -) -def test_elasticnet_model(datatype, solver, nrows, column_info, ntargets): - ncols, n_info = column_info - X_train, X_test, y_train, y_test = make_regression_dataset( - datatype, nrows, ncols, n_info, n_targets=ntargets - ) - - # Initialization of cuML's elastic net model - cuelastic = cuElasticNet(alpha=0.1, l1_ratio=0.5, solver=solver) - - if ntargets > 1: - with pytest.raises(ValueError, match="Expected 1 columns"): - cuelastic.fit(X_train, y_train) - return - - # fit and predict cuml elastic net model - cuelastic.fit(X_train, y_train) - cuelastic_predict = cuelastic.predict(X_test) - - if nrows < 500000: - # sklearn elastic net model initialization, fit and predict - skelastic = skElasticNet(alpha=0.1, l1_ratio=0.5) - skelastic.fit(X_train, y_train) - - skelastic_predict = skelastic.predict(X_test) - - assert array_equal( - skelastic_predict, - cuelastic_predict, - 3e-0, - total_tol=1e-0, - with_sign=True, - ) diff --git a/python/cuml/tests/test_sklearn_compatibility.py b/python/cuml/tests/test_sklearn_compatibility.py index 22922dad41..287abd2360 100644 --- a/python/cuml/tests/test_sklearn_compatibility.py +++ b/python/cuml/tests/test_sklearn_compatibility.py @@ -350,6 +350,7 @@ "check_supervised_y_2d": "Lasso does not handle 2D y", "check_supervised_y_no_nan": "Lasso does not check for NaN in y", "check_requires_y_none": "Lasso does not handle y=None", + "check_sample_weight_equivalence_on_sparse_data": "Lasso QN solver has issues with sample weights", }, ElasticNet: { "check_estimator_tags_renamed": "No support for modern tags infrastructure", @@ -365,6 +366,7 @@ "check_supervised_y_2d": "ElasticNet does not handle 2D y", "check_supervised_y_no_nan": "ElasticNet does not check for NaN in y", "check_requires_y_none": "ElasticNet does not handle y=None", + "check_sample_weight_equivalence_on_sparse_data": "ElasticNet QN solver has issues with sample weights", }, KernelDensity: { "check_estimator_tags_renamed": "No support for modern tags infrastructure",