Skip to content

Commit e8b70c0

Browse files
authored
FEA array API support for LogisticRegressionCV (scikit-learn#33906)
1 parent b83c94c commit e8b70c0

8 files changed

Lines changed: 319 additions & 117 deletions

File tree

doc/modules/array_api.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,7 @@ Estimators
163163
`svd_solver="randomized"` (`svd_solver="randomized"` only if `power_iteration_normalizer="QR"`))
164164
- :class:`kernel_approximation.Nystroem`
165165
- :class:`linear_model.LogisticRegression` (with `solver="lbfgs"` and `solver="newton-cg"`)
166+
- :class:`linear_model.LogisticRegressionCV` (with `solver="lbfgs"` and `solver="newton-cg"`)
166167
- :class:`linear_model.PoissonRegressor` (with `solver="lbfgs"`)
167168
- :class:`linear_model.Ridge` (with `solver="svd"`)
168169
- :class:`linear_model.RidgeCV` (see :ref:`device_support_for_float64`)
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
- :class:`linear_model.LogisticRegressionCV` now supports array API compatible inputs
2+
with `solver="lbfgs"` and `solver="newton-cg"` and when the underlying scoring
3+
function is also compatible with the array API.
4+
By :user:`Omar Salman <OmarManzoor>` and :user:`Christian Lorentzen <lorentzenchr>`.

sklearn/linear_model/_logistic.py

Lines changed: 131 additions & 102 deletions
Large diffs are not rendered by default.

sklearn/linear_model/tests/test_logistic.py

Lines changed: 131 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2879,12 +2879,10 @@ def test_logistic_regression_array_api_compliance(
28792879
# the intercept would be non-zero.
28802880
lr_params = dict(C=1e-2, solver=solver, max_iter=500, class_weight=class_weight)
28812881
lr_params["tol"] = 1e-6 if dtype_name == "float32" else 1e-12
2882-
with warnings.catch_warnings():
2883-
# Make sure that we converge in the reference fit.
2884-
lr_np = LogisticRegression(**lr_params).fit(
2885-
X_np, y_np, sample_weight=sample_weight
2886-
)
2887-
assert lr_np.n_iter_ < lr_np.max_iter
2882+
2883+
lr_np = LogisticRegression(**lr_params).fit(X_np, y_np, sample_weight=sample_weight)
2884+
# Make sure that the reference fit converged.
2885+
assert lr_np.n_iter_ < lr_np.max_iter
28882886

28892887
# Test that C was not too large for meaningful testing.
28902888
assert np.abs(lr_np.coef_).max() > 0.1
@@ -2900,14 +2898,11 @@ def test_logistic_regression_array_api_compliance(
29002898
rtol = 1e-4 if dtype_name == "float32" else 1e-8
29012899

29022900
with config_context(array_api_dispatch=True):
2903-
with warnings.catch_warnings():
2904-
# Make sure that we converge when using the namespace/device
2905-
# specific fit.
2906-
warnings.simplefilter("error", ConvergenceWarning)
2907-
lr_xp = LogisticRegression(**lr_params).fit(
2908-
X_xp, y_xp_or_np, sample_weight=sample_weight
2909-
)
2910-
2901+
lr_xp = LogisticRegression(**lr_params).fit(
2902+
X_xp, y_xp_or_np, sample_weight=sample_weight
2903+
)
2904+
# Make sure that we converge when using the namespace/device
2905+
# specific fit.
29112906
assert lr_xp.n_iter_.shape == lr_np.n_iter_.shape
29122907
assert int(lr_xp.n_iter_[0]) < lr_xp.max_iter
29132908

@@ -2946,6 +2941,128 @@ def test_logistic_regression_array_api_compliance(
29462941
assert_array_equal(prediction_xp, prediction_np)
29472942

29482943

2944+
@pytest.mark.parametrize("solver", ["lbfgs", "newton-cg"])
2945+
@pytest.mark.parametrize("refit", [True, False])
2946+
@pytest.mark.parametrize("binary", [False, True])
2947+
@pytest.mark.parametrize("use_str_y", [False, True])
2948+
@pytest.mark.parametrize("use_sample_weight", [False, True])
2949+
@pytest.mark.parametrize(
2950+
"array_namespace, device_name, dtype_name",
2951+
yield_namespace_device_dtype_combinations(),
2952+
)
2953+
@pytest.mark.filterwarnings("error::sklearn.exceptions.ConvergenceWarning")
2954+
def test_logistic_regression_cv_array_api_compliance(
2955+
solver,
2956+
refit,
2957+
binary,
2958+
use_str_y,
2959+
use_sample_weight,
2960+
array_namespace,
2961+
device_name,
2962+
dtype_name,
2963+
):
2964+
xp, device = _array_api_for_tests(array_namespace, device_name, dtype_name)
2965+
X_np = iris.data.astype(dtype_name, copy=True)
2966+
n_samples, _ = X_np.shape
2967+
X_xp = xp.asarray(X_np, device=device)
2968+
if use_str_y:
2969+
if binary:
2970+
target = (iris.target > 0).astype(np.int64)
2971+
target = np.array(["setosa", "not-setosa"])[target]
2972+
class_weight = {"setosa": 1.0, "not-setosa": 3.0}
2973+
else:
2974+
target = iris.target_names[iris.target]
2975+
class_weight = {"virginica": 1.0, "setosa": 2.0, "versicolor": 3.0}
2976+
y_np = target.copy()
2977+
y_xp_or_np = np.asarray(y_np, copy=True)
2978+
else:
2979+
if binary:
2980+
target = (iris.target > 0).astype(np.int64)
2981+
class_weight = {0: 1.0, 1: 3.0}
2982+
else:
2983+
target = iris.target
2984+
class_weight = {0: 1.0, 1: 2.0, 2: 3.0}
2985+
y_np = target.astype(dtype_name)
2986+
y_xp_or_np = xp.asarray(y_np, device=device)
2987+
2988+
if use_sample_weight:
2989+
sample_weight = (
2990+
np.random.default_rng(0)
2991+
.uniform(-1, 5, size=n_samples)
2992+
.clip(0, None)
2993+
.astype(dtype_name)
2994+
)
2995+
else:
2996+
sample_weight = None
2997+
2998+
cv = StratifiedKFold(2, shuffle=False)
2999+
precomputed_folds = list(cv.split(X_np, y_np))
3000+
atol = _atol_for_type(dtype_name) * 10
3001+
if dtype_name == "float32":
3002+
# TODO: this test is currently very sensitive to the value
3003+
# of the solver tol, particularly for "lbgfs". The rtol value
3004+
# is also quite high. Therefore try to investigate the numerical
3005+
# stability of the convergence with "lbgfs" in a dedicated
3006+
# follow up issue and PR.
3007+
if solver == "lbfgs":
3008+
solver_tol = 5e-3
3009+
rtol = 6e-2
3010+
else:
3011+
solver_tol = 1e-5
3012+
rtol = 5e-3
3013+
else:
3014+
solver_tol = 1e-10
3015+
rtol = 5e-5
3016+
3017+
lr_cv_params = dict(
3018+
Cs=[0.01, 0.001],
3019+
cv=precomputed_folds,
3020+
solver=solver,
3021+
tol=solver_tol,
3022+
max_iter=200,
3023+
class_weight=class_weight,
3024+
scoring="neg_log_loss",
3025+
use_legacy_attributes=False,
3026+
refit=refit,
3027+
)
3028+
3029+
lr_cv_np = LogisticRegressionCV(**lr_cv_params).fit(
3030+
X_np, y_np, sample_weight=sample_weight
3031+
)
3032+
# Make sure that the reference fit converged.
3033+
assert np.max(lr_cv_np.n_iter_) < lr_cv_np.max_iter
3034+
3035+
# Test that C was not too large for meaningful testing.
3036+
assert np.abs(lr_cv_np.coef_).max() > 0.1
3037+
3038+
prediction_np = lr_cv_np.predict(X_np)
3039+
score_np = lr_cv_np.score(X_np, y_np)
3040+
xp, _ = _array_api_for_tests(array_namespace, device_name)
3041+
with config_context(array_api_dispatch=True):
3042+
lr_cv_xp = LogisticRegressionCV(**lr_cv_params).fit(
3043+
X_xp, y_xp_or_np, sample_weight=sample_weight
3044+
)
3045+
assert lr_cv_xp.n_iter_.shape == lr_cv_np.n_iter_.shape
3046+
assert xp.max(lr_cv_xp.n_iter_) < lr_cv_xp.max_iter
3047+
3048+
for attr_name in ("scores_", "coefs_paths_", "coef_", "intercept_"):
3049+
attr_xp = getattr(lr_cv_xp, attr_name)
3050+
attr_np = getattr(lr_cv_np, attr_name)
3051+
assert_allclose(
3052+
move_to(attr_xp, xp=np, device="cpu"), attr_np, rtol=rtol, atol=atol
3053+
)
3054+
assert attr_xp.dtype == X_xp.dtype
3055+
assert array_api_device(attr_xp) == array_api_device(X_xp)
3056+
3057+
prediction_xp = lr_cv_xp.predict(X_xp)
3058+
if not use_str_y:
3059+
prediction_xp = move_to(prediction_xp, xp=np, device="cpu")
3060+
assert_array_equal(prediction_xp, prediction_np)
3061+
3062+
score_xp = lr_cv_xp.score(X_xp, y_xp_or_np)
3063+
assert_allclose(score_xp, score_np, rtol=rtol, atol=atol)
3064+
3065+
29493066
# TODO(1.10): remove when penalty is removed
29503067
@pytest.mark.filterwarnings("ignore:'penalty' was deprecated")
29513068
@pytest.mark.parametrize("penalty, l1_ratio", [("l1", 0.0), ("l2", 1.0)])

sklearn/utils/_array_api.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1357,3 +1357,31 @@ def _matching_numpy_dtype(X, xp=None):
13571357
reversed_dtypes_dict = {dtype: name for name, dtype in dtypes_dict.items()}
13581358
dtype_name = reversed_dtypes_dict[X.dtype]
13591359
return np_compat.__array_namespace_info__().dtypes()[dtype_name]
1360+
1361+
1362+
def _swapaxes(array, axis1, axis2, /, xp=None):
1363+
xp, _ = get_namespace(array)
1364+
if hasattr(xp, "swapaxes"):
1365+
return xp.swapaxes(array, axis1, axis2)
1366+
1367+
ndim = array.ndim
1368+
a1 = axis1 if axis1 >= 0 else axis1 + ndim
1369+
a2 = axis2 if axis2 >= 0 else axis2 + ndim
1370+
axes = list(range(ndim))
1371+
axes[a1], axes[a2] = axes[a2], axes[a1]
1372+
return xp.permute_dims(array, axes=tuple(axes))
1373+
1374+
1375+
def _unravel_index(indices, shape, /, xp=None):
1376+
# TODO: remove this and use the respective array-api-extra function when
1377+
# version 0.11.1 is released.
1378+
# https://data-apis.org/array-api-extra/generated/array_api_extra.unravel_index.html
1379+
xp, _ = get_namespace(indices)
1380+
if hasattr(xp, "unravel_index"):
1381+
return xp.unravel_index(indices, shape)
1382+
1383+
coordinates = []
1384+
for dim in reversed(shape):
1385+
coordinates.append(indices % dim)
1386+
indices = indices // dim
1387+
return tuple(reversed(coordinates))

sklearn/utils/_response.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import numpy as np
1010

1111
from sklearn.base import is_classifier
12+
from sklearn.utils._array_api import _ravel, get_namespace
1213
from sklearn.utils.multiclass import type_of_target
1314
from sklearn.utils.validation import _check_response_method, check_is_fitted
1415

@@ -57,7 +58,8 @@ def _process_predict_proba(*, y_pred, target_type, classes, pos_label):
5758
)
5859

5960
if target_type == "binary":
60-
col_idx = np.flatnonzero(classes == pos_label)[0]
61+
xp, _ = get_namespace(classes)
62+
col_idx = int(xp.nonzero(_ravel(classes == pos_label))[0][0])
6163
return y_pred[:, col_idx]
6264
elif target_type == "multilabel-indicator":
6365
# Use a compress format of shape `(n_samples, n_output)`.

sklearn/utils/estimator_checks.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1164,6 +1164,12 @@ def _check_array_api_core(
11641164
# Fitted attributes which are arrays must have the same namespace as `X`,
11651165
# except `classes_`, to allow it to be string when `y` is string.
11661166
for attribute_name, attribute_value in array_attributes.items():
1167+
# TODO(1.10): remove with deprecation of default l1_ratios
1168+
if attribute_name == "l1_ratios_":
1169+
# l1_ratios_ can be None and so it is skipped in this test
1170+
# because it is not associated with any array namespace or device.
1171+
continue
1172+
11671173
est_xp_attr = getattr(est_xp, attribute_name)
11681174
# `classes_` should be in same ns and device as `y`
11691175
expected_xp, expected_ns = (

sklearn/utils/tests/test_array_api.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
_nanmean,
3131
_nanmin,
3232
_ravel,
33+
_swapaxes,
3334
_validate_diagonal_args,
3435
check_same_namespace,
3536
get_namespace,
@@ -1042,3 +1043,17 @@ def test_matching_numpy_dtype(namespace, device_name, dtype_name):
10421043
with config_context(array_api_dispatch=True):
10431044
ret_dtype = _matching_numpy_dtype(X_xp, xp=xp)
10441045
assert ret_dtype == X_np.dtype
1046+
1047+
1048+
@pytest.mark.parametrize(
1049+
"namespace, device_name, dtype_name",
1050+
yield_namespace_device_dtype_combinations(),
1051+
)
1052+
def test_swapaxes(namespace, device_name, dtype_name):
1053+
xp, device = _array_api_for_tests(namespace, device_name, dtype_name)
1054+
X_np = numpy.arange(10).reshape(5, 2).astype(dtype_name)
1055+
X_xp = xp.asarray(X_np, device=device)
1056+
result_np = numpy.swapaxes(X_np, 0, 1)
1057+
with config_context(array_api_dispatch=True):
1058+
result_xp = _swapaxes(X_xp, 0, 1)
1059+
assert_array_equal(move_to(result_xp, xp=numpy, device="cpu"), result_np)

0 commit comments

Comments
 (0)