diff --git a/doc/whats_new/_contributors.rst b/doc/whats_new/_contributors.rst index ca0f8ede93afa..f7b44760f5ab9 100644 --- a/doc/whats_new/_contributors.rst +++ b/doc/whats_new/_contributors.rst @@ -176,4 +176,6 @@ .. _Nicolas Hug: https://github.com/NicolasHug -.. _Guillaume Lemaitre: https://github.com/glemaitre \ No newline at end of file +.. _Guillaume Lemaitre: https://github.com/glemaitre + +.. _Xiao Wei: https://github.com/xiaowei1234 \ No newline at end of file diff --git a/doc/whats_new/v1.1.rst b/doc/whats_new/v1.1.rst index 19cad519eb5a6..65bf1d01ef1a0 100644 --- a/doc/whats_new/v1.1.rst +++ b/doc/whats_new/v1.1.rst @@ -537,6 +537,12 @@ Changelog `alpha`, `max_iter` and `tol`. :pr:`22240` by :user:`Arturo Amor `. +- |Enhancement| :class:`linear_model.PoissonRegressor`, `linear_model.GammaRegressor`, + and `linear_model.TweedieRegressor` fit methods will now allow penalty term `alpha` + to be an iterable of same length as the number of features in X in addition to a + scalar value. + :pr:`22485` by :user:`Xiao Wei `. + - |Fix| :class:`linear_model.LogisticRegression` and :class:`linear_model.LogisticRegressionCV` now set the `n_iter_` attribute with a shape that respects the docstring and that is consistent with the shape diff --git a/sklearn/linear_model/_glm/glm.py b/sklearn/linear_model/_glm/glm.py index d7af8ae60d8b6..1bc327b4cde42 100644 --- a/sklearn/linear_model/_glm/glm.py +++ b/sklearn/linear_model/_glm/glm.py @@ -7,6 +7,7 @@ # License: BSD 3 clause import numbers +from collections.abc import Iterable import numpy as np import scipy.optimize @@ -68,12 +69,16 @@ class GeneralizedLinearRegressor(RegressorMixin, BaseEstimator): Parameters ---------- - alpha : float, default=1 - Constant that multiplies the penalty term and thus determines the + alpha : {float, array-like}, default=1 + Constant(s) that multiplies the penalty term and thus determines the regularization strength. ``alpha = 0`` is equivalent to unpenalized GLMs. In this case, the design matrix `X` must have full column rank (no collinearities). Values must be in the range `[0.0, inf)`. + If alpha is a scalar then the value is applied to all non-intercept terms + If alpha is an array-like then each value must be in the range `[0.0, inf)` + and the length must equal to n_features. + If alpha is greater than 1 dimension it will be converted to 1 dimension. fit_intercept : bool, default=True Specifies if a constant (a.k.a. bias or intercept) should be @@ -213,14 +218,6 @@ def fit(self, X, y, sample_weight=None): "an element of ['auto', 'identity', 'log']; " "got (link={0})".format(self.link) ) - - check_scalar( - self.alpha, - name="alpha", - target_type=numbers.Real, - min_val=0.0, - include_boundaries="left", - ) if not isinstance(self.fit_intercept, bool): raise ValueError( "The argument fit_intercept must be bool; got {0}".format( @@ -268,7 +265,29 @@ def fit(self, X, y, sample_weight=None): y_numeric=True, multi_output=False, ) - + if isinstance(self.alpha, Iterable) and not isinstance(self.alpha, str): + for i, val in enumerate(self.alpha): + check_scalar( + val, + name=f"alpha at index {i}", + target_type=numbers.Real, + min_val=0.0, + include_boundaries="left", + ) + self.alpha = np.asarray(self.alpha, dtype=np.float64).ravel() + if self.alpha.size != X.shape[1]: + raise ValueError( + f"X width is {X.shape[1]} while alpha is of length" + f" {self.alpha.size}" + ) + else: + check_scalar( + self.alpha, + name="alpha", + target_type=numbers.Real, + min_val=0.0, + include_boundaries="left", + ) weights = _check_sample_weight(sample_weight, X) _, n_features = X.shape diff --git a/sklearn/linear_model/_glm/tests/test_glm.py b/sklearn/linear_model/_glm/tests/test_glm.py index 87fe2b51f4d28..8ae4f5e629d84 100644 --- a/sklearn/linear_model/_glm/tests/test_glm.py +++ b/sklearn/linear_model/_glm/tests/test_glm.py @@ -195,6 +195,60 @@ def test_glm_scalar_argument(Estimator, params, err_type, err_msg): glm.fit(X, y) +@pytest.mark.parametrize( + "Estimator", + [GeneralizedLinearRegressor, PoissonRegressor, GammaRegressor, TweedieRegressor], +) +@pytest.mark.parametrize( + "params, err_type, err_msg", + [ + ( + {"alpha": [1, "2"]}, + TypeError, + "alpha at index 1 must be an instance of float, not str.", + ), + ( + {"alpha": [1, 2, 3], "fit_intercept": True}, + ValueError, + "X width is 2 while alpha is of length 3", + ), + ( + {"alpha": [1, 2, 3], "fit_intercept": False}, + ValueError, + "X width is 2 while alpha is of length 3", + ), + ({"alpha": [-2, 2]}, ValueError, "alpha at index 0 == -2, must be >= 0.0"), + ], +) +def test_glm_alpha_array(Estimator, params, err_type, err_msg): + """Test GLM for invalid alpha input when alpha is an iterable""" + X = [[1, 2], [2, 4]] + y = [1, 2] + glm = Estimator(**params) + with pytest.raises(err_type, match=err_msg): + glm.fit(X, y) + + +@pytest.mark.parametrize( + "Estimator", + [GeneralizedLinearRegressor, PoissonRegressor, GammaRegressor, TweedieRegressor], +) +def test_glm_alpha_array_reg(Estimator): + """Test GLM regression when alpha is an array and 2nd column + has different alpha than 1st column + """ + X = np.asarray([[1, 2], [1, 3], [1, 4], [1, 3]]) + y = np.asarray([2, 2, 3, 2]) + scalar_coefs = Estimator(alpha=1.0, fit_intercept=False).fit(X, y).coef_ + X_scaled = X.copy() + X_scaled[:, 1] = X_scaled[:, 1] * 2.0 + array_coefs = ( + Estimator(alpha=[1.0, 4.0], fit_intercept=False).fit(X_scaled, y).coef_ + ) + array_coefs[1] *= 2.0 + assert_allclose(scalar_coefs, array_coefs, atol=1e-4) + + @pytest.mark.parametrize("warm_start", ["not bool", 1, 0, [True]]) def test_glm_warm_start_argument(warm_start): """Test GLM for invalid warm_start argument."""