Skip to content

Commit add0e83

Browse files
ENH add Array API to newton-cg in LogisticRegression (scikit-learn#34412)
Co-authored-by: Omar Salman <omar.salman@arbisoft.com>
1 parent a62d492 commit add0e83

11 files changed

Lines changed: 281 additions & 156 deletions

File tree

doc/modules/array_api.rst

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -162,7 +162,7 @@ Estimators
162162
- :class:`decomposition.PCA` (with `svd_solver="full"`, `svd_solver="covariance_eigh"`, or
163163
`svd_solver="randomized"` (`svd_solver="randomized"` only if `power_iteration_normalizer="QR"`))
164164
- :class:`kernel_approximation.Nystroem`
165-
- :class:`linear_model.LogisticRegression` (with `solver="lbfgs"`)
165+
- :class:`linear_model.LogisticRegression` (with `solver="lbfgs"` and `solver="newton-cg"`)
166166
- :class:`linear_model.PoissonRegressor` (with `solver="lbfgs"`)
167167
- :class:`linear_model.Ridge` (with `solver="svd"`)
168168
- :class:`linear_model.RidgeCV` (see :ref:`device_support_for_float64`)
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
:class:`linear_model.LogisticRegression` now supports array API compatible inputs
2+
with `solver="newton-cg"`.
3+
By :user:`Christian Lorentzen <lorentzenchr>` and :user:`Omar Salman <OmarManzoor>`.
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
- The `solver="newton-cg"` for :class:`linear_model.LogisticRegression`,
2+
:class:`linear_model.PoissonRegressor` and other GLMs is now more robust. Many
3+
convergence issues have been fixed, e.g. for certain ill-conditioned problems with
4+
low regularization and nearly collinear features. The fix is about treating small
5+
or even negative curvature.
6+
By :user:`Christian Lorentzen <lorentzenchr>`.

sklearn/_loss/tests/test_loss.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1411,6 +1411,9 @@ def test_loss_array_api(
14111411
device_name,
14121412
dtype_name,
14131413
):
1414+
if loss_class == HalfMultinomialLoss and method_name == "gradient_hessian":
1415+
pytest.skip("Not implemented")
1416+
14141417
def _assert_array_api_result(
14151418
result_xp, result_np, raw_prediction_xp, xp, rtol, atol
14161419
):

sklearn/linear_model/_linear_loss.py

Lines changed: 75 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@
99
from scipy import sparse
1010

1111
from sklearn.utils._array_api import (
12+
_is_numpy_namespace,
13+
_ravel,
1214
get_namespace,
1315
get_namespace_and_device,
1416
move_to,
@@ -172,7 +174,11 @@ def weight_intercept(self, coef):
172174
else:
173175
# reshape to (n_classes, n_dof)
174176
if coef.ndim == 1:
175-
weights = coef.reshape((self.base_loss.n_classes, -1), order="F")
177+
xp, _ = get_namespace(coef)
178+
if _is_numpy_namespace(xp):
179+
weights = coef.reshape((self.base_loss.n_classes, -1), order="F")
180+
else:
181+
weights = xp.reshape(coef, (-1, self.base_loss.n_classes)).T
176182
else:
177183
weights = coef
178184
if self.fit_intercept:
@@ -351,6 +357,11 @@ def loss_gradient(
351357

352358
grad_pointwise /= sw_sum
353359

360+
# TODO: The lbfgs solver currently only works with numpy arrays and expects
361+
# both the coef argument and the result to be in numpy. So, for the time
362+
# being, we stick with a mix of numpy and array API. We could consider
363+
# moving coef to the device of X if lbgfs also becomes compatible with the
364+
# array API.
354365
if not self.base_loss.is_multiclass:
355366
grad = np.empty_like(coef, dtype=weights.dtype)
356367
X_grad = X.T @ grad_pointwise
@@ -431,23 +442,30 @@ def gradient(
431442
sample_weight=sample_weight,
432443
n_threads=n_threads,
433444
)
434-
sw_sum = n_samples if sample_weight is None else np.sum(sample_weight)
445+
xp, _, device = get_namespace_and_device(X, y, sample_weight)
446+
sw_sum = n_samples if sample_weight is None else xp.sum(sample_weight)
435447
grad_pointwise /= sw_sum
436448

437449
if not self.base_loss.is_multiclass:
438-
grad = np.empty_like(coef, dtype=weights.dtype)
450+
grad = xp.empty_like(coef, dtype=weights.dtype, device=device)
439451
grad[:n_features] = X.T @ grad_pointwise + l2_reg_strength * weights
440452
if self.fit_intercept:
441-
grad[-1] = grad_pointwise.sum()
453+
grad[-1] = xp.sum(grad_pointwise)
442454
return grad
443455
else:
444-
grad = np.empty((n_classes, n_dof), dtype=weights.dtype, order="F")
456+
if _is_numpy_namespace(xp):
457+
grad = np.empty((n_classes, n_dof), dtype=weights.dtype, order="F")
458+
else:
459+
grad = xp.empty((n_classes, n_dof), dtype=weights.dtype, device=device)
445460
# gradient.shape = (n_samples, n_classes)
446461
grad[:, :n_features] = grad_pointwise.T @ X + l2_reg_strength * weights
447462
if self.fit_intercept:
448-
grad[:, -1] = grad_pointwise.sum(axis=0)
463+
grad[:, -1] = xp.sum(grad_pointwise, axis=0)
449464
if coef.ndim == 1:
450-
return grad.ravel(order="F")
465+
if _is_numpy_namespace(xp):
466+
return grad.ravel(order="F")
467+
else:
468+
return _ravel(grad.T, xp=xp)
451469
else:
452470
return grad
453471

@@ -756,7 +774,9 @@ def gradient_hessian_product(
756774
(n_samples, n_features), n_classes = X.shape, self.base_loss.n_classes
757775
n_dof = n_features + int(self.fit_intercept)
758776
weights, intercept, raw_prediction = self.weight_intercept_raw(coef, X)
759-
sw_sum = n_samples if sample_weight is None else np.sum(sample_weight)
777+
xp, _, device = get_namespace_and_device(X, y, sample_weight)
778+
is_numpy_ns = _is_numpy_namespace(xp)
779+
sw_sum = n_samples if sample_weight is None else xp.sum(sample_weight)
760780

761781
if not self.base_loss.is_multiclass:
762782
grad_pointwise, hess_pointwise = self.base_loss.gradient_hessian(
@@ -767,39 +787,45 @@ def gradient_hessian_product(
767787
)
768788
grad_pointwise /= sw_sum
769789
hess_pointwise /= sw_sum
770-
grad = np.empty_like(coef, dtype=weights.dtype)
790+
grad = xp.empty_like(coef, dtype=weights.dtype)
771791
grad[:n_features] = X.T @ grad_pointwise + l2_reg_strength * weights
772792
if self.fit_intercept:
773-
grad[-1] = grad_pointwise.sum()
793+
grad[-1] = xp.sum(grad_pointwise)
774794

775795
# Precompute as much as possible: hX, hX_sum and hessian_sum
776-
hessian_sum = hess_pointwise.sum()
796+
hessian_sum = xp.sum(hess_pointwise)
777797
if sparse.issparse(X):
778798
hX = (
779799
sparse.dia_array((hess_pointwise, 0), shape=(n_samples, n_samples))
780800
@ X
781801
)
782802
else:
783-
hX = hess_pointwise[:, np.newaxis] * X
803+
hX = hess_pointwise[:, None] * X
784804

785805
if self.fit_intercept:
786-
# Calculate the double derivative with respect to intercept.
787-
# Note: In case hX is sparse, hX.sum is a matrix object.
788-
hX_sum = np.squeeze(np.asarray(hX.sum(axis=0)))
789-
# prevent squeezing to zero-dim array if n_features == 1
790-
hX_sum = np.atleast_1d(hX_sum)
806+
# Calculate the double derivative with respect to the intercept.
807+
if sparse.issparse(X):
808+
# Note: In case hX is sparse, hX.sum is a matrix object.
809+
hX_sum = xp.asarray(hX.sum(axis=0))
810+
else:
811+
hX_sum = xp.sum(hX, axis=0)
791812

792813
# With intercept included and l2_reg_strength = 0, hessp returns
793814
# res = (X, 1)' @ diag(h) @ (X, 1) @ s
794815
# = (X, 1)' @ (hX @ s[:n_features], sum(h) * s[-1])
795816
# res[:n_features] = X' @ hX @ s[:n_features] + sum(h) * s[-1]
796817
# res[-1] = 1' @ hX @ s[:n_features] + sum(h) * s[-1]
797818
def hessp(s):
798-
ret = np.empty_like(s)
819+
ret = xp.empty_like(s)
799820
if sparse.issparse(X):
800821
ret[:n_features] = X.T @ (hX @ s[:n_features])
801822
else:
802-
ret[:n_features] = np.linalg.multi_dot([X.T, hX, s[:n_features]])
823+
if is_numpy_ns:
824+
ret[:n_features] = np.linalg.multi_dot(
825+
[X.T, hX, s[:n_features]]
826+
)
827+
else:
828+
ret[:n_features] = X.T @ (hX @ s[:n_features])
803829
ret[:n_features] += l2_reg_strength * s[:n_features]
804830

805831
if self.fit_intercept:
@@ -820,10 +846,13 @@ def hessp(s):
820846
n_threads=n_threads,
821847
)
822848
grad_pointwise /= sw_sum
823-
grad = np.empty((n_classes, n_dof), dtype=weights.dtype, order="F")
849+
if is_numpy_ns:
850+
grad = np.empty((n_classes, n_dof), dtype=weights.dtype, order="F")
851+
else:
852+
grad = xp.empty((n_classes, n_dof), dtype=weights.dtype, device=device)
824853
grad[:, :n_features] = grad_pointwise.T @ X + l2_reg_strength * weights
825854
if self.fit_intercept:
826-
grad[:, -1] = grad_pointwise.sum(axis=0)
855+
grad[:, -1] = xp.sum(grad_pointwise, axis=0)
827856

828857
# Full hessian-vector product, i.e. not only the diagonal part of the
829858
# hessian. Derivation with some index battle for input vector s:
@@ -847,29 +876,47 @@ def hessp(s):
847876
#
848877
# See also https://github.com/scikit-learn/scikit-learn/pull/3646#discussion_r17461411
849878
def hessp(s):
850-
s = s.reshape((n_classes, -1), order="F") # shape = (n_classes, n_dof)
879+
if is_numpy_ns:
880+
s = s.reshape(
881+
(n_classes, -1), order="F"
882+
) # shape = (n_classes, n_dof)
883+
else:
884+
s = xp.reshape(s, (-1, n_classes)).T
851885
if self.fit_intercept:
852886
s_intercept = s[:, -1]
853887
s = s[:, :-1] # shape = (n_classes, n_features)
854888
else:
855889
s_intercept = 0
856890
tmp = X @ s.T + s_intercept # X_{im} * s_k_m
857-
tmp -= (proba * tmp).sum(axis=1)[:, np.newaxis] # - sum_l ..
891+
tmp -= xp.sum(proba * tmp, axis=1)[:, None] # - sum_l ..
858892
tmp *= proba # * p_i_k
859893
if sample_weight is not None:
860-
tmp *= sample_weight[:, np.newaxis]
894+
tmp *= sample_weight[:, None]
861895
# hess_prod = empty_like(grad), but we ravel grad below and this
862896
# function is run after that.
863-
hess_prod = np.empty((n_classes, n_dof), dtype=weights.dtype, order="F")
897+
if is_numpy_ns:
898+
hess_prod = np.empty(
899+
(n_classes, n_dof), dtype=weights.dtype, order="F"
900+
)
901+
else:
902+
hess_prod = xp.empty(
903+
(n_classes, n_dof), dtype=weights.dtype, device=device
904+
)
864905
hess_prod[:, :n_features] = (tmp.T @ X) / sw_sum + l2_reg_strength * s
865906
if self.fit_intercept:
866-
hess_prod[:, -1] = tmp.sum(axis=0) / sw_sum
907+
hess_prod[:, -1] = xp.sum(tmp, axis=0) / sw_sum
867908
if coef.ndim == 1:
868-
return hess_prod.ravel(order="F")
909+
if is_numpy_ns:
910+
return hess_prod.ravel(order="F")
911+
else:
912+
return _ravel(hess_prod.T, xp=xp)
869913
else:
870914
return hess_prod
871915

872916
if coef.ndim == 1:
873-
return grad.ravel(order="F"), hessp
917+
if is_numpy_ns:
918+
return grad.ravel(order="F"), hessp
919+
else:
920+
return _ravel(grad.T, xp=xp), hessp
874921

875922
return grad, hessp

0 commit comments

Comments
 (0)