Skip to content

Commit 22aaa49

Browse files
[ENH] enable array API support in LogisticRegression (#2941)
* Initial refactoring of LogReg * Align Logistic Regression SPMD estimator with LinReg SPMD estimator * Update spmd example * Remove redundant comments, minor updates * Fix several bugs: * Add mix inputs support * Add arbitrary binary label support * Add clean up after new fit is called * Raise exception if spmd estimator is called with cpu data * Update fallbacks * Code format and update docs * Update checks for array api input * Code format * Fix test_logistic_regression_array_api_compliance test failures * Doc fixes * Change conversion in predict method * Ensure LogReg execution with raw inputs happens on GPU * Fix docs and enable decision function in spmd mode * Fix error * Update wrap_output method for Array API inputs * Fix tests in test_patching * Add simple logreg array api test * Fix * Minor doc update * Fix logreg array api test and predit function * Fix test * Code cleanup * Deselect failing test on windows * Handle unique_values for non-array api inputs
1 parent 828ef09 commit 22aaa49

File tree

13 files changed

+409
-272
lines changed

13 files changed

+409
-272
lines changed

daal4py/sklearn/linear_model/logistic_path.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,13 @@
3939
import daal4py as d4p
4040

4141
from .._n_jobs_support import control_n_jobs
42-
from .._utils import PatchingConditionsChain, getFPType, is_sparse, sklearn_check_version
42+
from .._utils import (
43+
PatchingConditionsChain,
44+
check_is_array_api,
45+
getFPType,
46+
is_sparse,
47+
sklearn_check_version,
48+
)
4349
from ..utils.validation import check_feature_names
4450
from .logistic_loss import (
4551
_daal4py_cross_entropy_loss_extra_args,
@@ -444,7 +450,7 @@ def daal4py_predict(self, X, resultsToEvaluate):
444450
_dal_ready = _patching_status.and_conditions(
445451
[
446452
(
447-
not ((not isinstance(X, np.ndarray)) and hasattr(X, "__dlpack__")),
453+
not check_is_array_api(X),
448454
"Array API inputs not supported.",
449455
)
450456
]
@@ -772,7 +778,7 @@ def logistic_regression_path_dispatcher(
772778
),
773779
(not is_sparse(X), "X is sparse. Sparse input is not supported."),
774780
(
775-
not ((not isinstance(X, np.ndarray)) and hasattr(X, "__dlpack__")),
781+
not check_is_array_api(X),
776782
"Array API inputs not supported.",
777783
),
778784
(sample_weight is None, "Sample weights are not supported."),

deselected_tests.yaml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -387,6 +387,11 @@ deselected_tests:
387387
- linear_model/tests/test_logistic.py::test_logistic_cv_sparse[42-csr_matrix]
388388
- linear_model/tests/test_logistic.py::test_logistic_cv_sparse[42-csr_array]
389389
- tests/test_common.py::test_estimators[LogisticRegressionCV(cv=3,max_iter=5,use_legacy_attributes=False)-check_sample_weight_equivalence_on_dense_data]
390+
- linear_model/tests/test_logistic.py::test_logistic_regression_array_api_compliance[array_api_strict-CPU_DEVICE-float64-None-False-False-False]
391+
- linear_model/tests/test_logistic.py::test_logistic_regression_array_api_compliance[array_api_strict-CPU_DEVICE-float64-None-False-True-False]
392+
393+
# Logistic regression array api compliance test fails on Windows only due to convergence issue
394+
- linear_model/tests/test_logistic.py::test_logistic_regression_array_api_compliance[array_api_strict-device1-float32-balanced-True-True-True]
390395

391396
# Scikit-learn does not constraint multinomial logistic intercepts to sum to zero.
392397
# Softmax function is invariant to additions by a constant, so even though the numbers

doc/sources/algorithms.rst

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -677,7 +677,7 @@ Classification
677677
- ``intercept_scaling`` != `1`
678678
- ``warm_start`` = ``True``
679679
- ``l1_ratio`` != ``0``
680-
- No limitations
680+
- Method ``score`` is not supported.
681681
- Only binary classification is supported
682682

683683
Regression

doc/sources/array_api.rst

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@ The following patched classes have support for array API inputs:
102102
- :obj:`sklearn.ensemble.RandomForestClassifier`
103103
- :obj:`sklearn.ensemble.RandomForestRegressor`
104104
- :obj:`sklearn.linear_model.LinearRegression`
105+
- :obj:`sklearn.linear_model.LogisticRegression`
105106
- :obj:`sklearn.linear_model.Ridge`
106107
- :obj:`sklearnex.linear_model.IncrementalLinearRegression`
107108
- :obj:`sklearnex.linear_model.IncrementalRidge`
@@ -165,6 +166,10 @@ Note that some cases of estimator-specific methods are still fully array API com
165166
for example, :meth:`sklearn.neighbors.NearestNeighbors.kneighbors` will produce outputs
166167
of array API classes when fitted to them.
167168

169+
For :obj:`sklearn.linear_model.LogisticRegression`, array API coverage is limited to cases where the input array
170+
is allocated on a GPU device, so passing array API inputs on CPU other than NumPy arrays will not result
171+
in calling accelerated routines from the |sklearnex|.
172+
168173
Example usage
169174
=============
170175

examples/sklearnex/logistic_regression_spmd.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ def generate_X_y(par, seed):
6868
dpnp_y_train = dpnp.asarray(y_train, usm_type="device", sycl_queue=q)
6969
dpnp_X_test = dpnp.asarray(X_test, usm_type="device", sycl_queue=q)
7070

71-
model_spmd = LogisticRegression()
71+
model_spmd = LogisticRegression(solver="newton-cg")
7272
model_spmd.fit(dpnp_X_train, dpnp_y_train)
7373

7474
y_predict = model_spmd.predict(dpnp_X_test)

onedal/linear_model/logistic_regression.py

Lines changed: 35 additions & 185 deletions
Original file line numberDiff line numberDiff line change
@@ -15,28 +15,16 @@
1515
# ==============================================================================
1616

1717
from abc import ABCMeta, abstractmethod
18-
from numbers import Number
1918

2019
import numpy as np
2120

22-
from daal4py.sklearn._utils import daal_check_version, get_dtype, make2d
21+
from daal4py.sklearn._utils import daal_check_version
2322
from onedal._device_offload import supports_queue
2423
from onedal.common._backend import bind_default_backend
25-
from onedal.utils import _sycl_queue_manager as QM
2624

27-
from .._config import _get_config
2825
from ..common._estimator_checks import _check_is_fitted
29-
from ..common._mixin import ClassifierMixin
3026
from ..datatypes import from_table, to_table
31-
from ..utils._array_api import _get_sycl_namespace
32-
from ..utils.validation import (
33-
_check_array,
34-
_check_n_features,
35-
_check_X_y,
36-
_is_csr,
37-
_num_features,
38-
_type_of_target,
39-
)
27+
from ..utils.validation import _check_n_features, _is_csr, _num_features
4028

4129

4230
class BaseLogisticRegression(metaclass=ABCMeta):
@@ -49,14 +37,13 @@ def __init__(self, tol, C, fit_intercept, solver, max_iter, algorithm):
4937
self.max_iter = max_iter
5038
self.algorithm = algorithm
5139

52-
@abstractmethod
53-
def train(self, params, X, y): ...
40+
@bind_default_backend("logistic_regression.classification")
41+
def train(self, params, X, y, queue=None): ...
5442

55-
@abstractmethod
56-
def infer(self, params, X): ...
43+
@bind_default_backend("logistic_regression.classification")
44+
def infer(self, params, model, X, queue=None): ...
5745

58-
# direct access to the backend model constructor
59-
@abstractmethod
46+
@bind_default_backend("logistic_regression.classification")
6047
def model(self): ...
6148

6249
def _get_onedal_params(self, is_csr, dtype=np.float32):
@@ -76,172 +63,64 @@ def _get_onedal_params(self, is_csr, dtype=np.float32):
7663
),
7764
}
7865

79-
def _fit(self, X, y):
80-
use_raw_input = _get_config()["use_raw_input"] is True
81-
82-
sparsity_enabled = daal_check_version((2024, "P", 700))
83-
if not use_raw_input:
84-
X, y = _check_X_y(
85-
X,
86-
y,
87-
accept_sparse=sparsity_enabled,
88-
force_all_finite=True,
89-
accept_2d_y=False,
90-
dtype=[np.float64, np.float32],
91-
)
92-
if _type_of_target(y) != "binary":
93-
raise ValueError("Only binary classification is supported")
94-
95-
self.classes_, y = np.unique(y, return_inverse=True)
96-
y = y.astype(dtype=np.int32)
97-
else:
98-
_, xp, _ = _get_sycl_namespace(X)
99-
# try catch needed for raw_inputs + array_api data where unlike
100-
# numpy the way to yield unique values is via `unique_values`
101-
# This should be removed when refactored for gpu zero-copy
102-
try:
103-
self.classes_ = xp.unique(y)
104-
except AttributeError:
105-
self.classes_ = xp.unique_values(y)
106-
107-
n_classes = len(self.classes_)
108-
if n_classes != 2:
109-
raise ValueError("Only binary classification is supported")
66+
@supports_queue
67+
def fit(self, X, y, queue=None):
68+
11069
is_csr = _is_csr(X)
11170

11271
self.n_features_in_ = _num_features(X, fallback_1d=True)
113-
X_table, y_table = to_table(X, y, queue=QM.get_global_queue())
72+
73+
X_table, y_table = to_table(X, y, queue=queue)
11474
params = self._get_onedal_params(is_csr, X_table.dtype)
11575

11676
result = self.train(params, X_table, y_table)
11777

11878
self._onedal_model = result.model
79+
11980
self.n_iter_ = np.array([result.iterations_count])
12081

12182
# _n_inner_iter is the total number of cg-solver iterations
12283
if daal_check_version((2024, "P", 300)) and self.solver == "newton-cg":
12384
self._n_inner_iter = result.inner_iterations_count
12485

125-
coeff = from_table(result.model.packed_coefficients)
86+
coeff = from_table(result.model.packed_coefficients, like=X)
12687
self.coef_, self.intercept_ = coeff[:, 1:], coeff[:, 0]
12788

12889
return self
12990

130-
def _create_model(self):
131-
m = self.model()
132-
133-
coefficients = self.coef_
134-
dtype = get_dtype(coefficients)
135-
coefficients = np.asarray(coefficients, dtype=dtype)
136-
137-
if coefficients.ndim == 2:
138-
n_features_in = coefficients.shape[1]
139-
assert coefficients.shape[0] == 1
140-
else:
141-
n_features_in = coefficients.size
142-
143-
intercept = self.intercept_
144-
if not isinstance(intercept, Number):
145-
intercept = np.asarray(intercept, dtype=dtype)
146-
assert intercept.size == 1
147-
148-
intercept = _check_array(
149-
intercept,
150-
dtype=[np.float64, np.float32],
151-
force_all_finite=True,
152-
ensure_2d=False,
153-
)
154-
coefficients = _check_array(
155-
coefficients,
156-
dtype=[np.float64, np.float32],
157-
force_all_finite=True,
158-
ensure_2d=False,
159-
)
160-
161-
coefficients, intercept = make2d(coefficients), make2d(intercept)
162-
163-
assert coefficients.shape == (1, n_features_in)
164-
assert intercept.shape == (1, 1)
165-
166-
desired_shape = (1, n_features_in + 1)
167-
packed_coefficients = np.zeros(desired_shape, dtype=dtype)
168-
169-
packed_coefficients[:, 1:] = coefficients
170-
if self.fit_intercept:
171-
packed_coefficients[:, 0][:, np.newaxis] = intercept
172-
173-
m.packed_coefficients = to_table(packed_coefficients, queue=QM.get_global_queue())
174-
175-
self._onedal_model = m
176-
177-
return m
178-
179-
def _infer(self, X):
91+
def _infer(self, X, queue=None):
18092
_check_is_fitted(self)
18193

182-
sparsity_enabled = daal_check_version((2024, "P", 700))
183-
184-
if not _get_config()["use_raw_input"]:
185-
X = _check_array(
186-
X,
187-
dtype=[np.float64, np.float32],
188-
accept_sparse=sparsity_enabled,
189-
force_all_finite=True,
190-
ensure_2d=False,
191-
accept_large_sparse=sparsity_enabled,
192-
)
19394
is_csr = _is_csr(X)
194-
_check_n_features(self, X, False)
19595

196-
X = make2d(X)
96+
_check_n_features(self, X, False)
19797

198-
if hasattr(self, "_onedal_model"):
199-
model = self._onedal_model
200-
else:
201-
model = self._create_model()
98+
assert hasattr(self, "_onedal_model")
20299

203-
X_table = to_table(X, queue=QM.get_global_queue())
204-
params = self._get_onedal_params(is_csr, X.dtype)
100+
X_table = to_table(X, queue=queue)
101+
params = self._get_onedal_params(is_csr, X_table.dtype)
205102

206-
result = self.infer(params, model, X_table)
103+
result = self.infer(params, self._onedal_model, X_table)
207104
return result
208105

209-
def _predict(self, X):
210-
result = self._infer(X)
211-
_, xp, _ = _get_sycl_namespace(X)
212-
y = from_table(result.responses, like=X)
213-
y = xp.take(xp.asarray(self.classes_), xp.reshape(y, (-1,)), axis=0)
106+
@supports_queue
107+
def predict(self, X, queue=None, classes=None):
108+
result = self._infer(X, queue)
109+
110+
# Starting from sklearn 1.9 type of predicted labels should match the type of self.classes_
111+
# In general case, classes attribute is provided from sklearnex estimator
112+
# In case it's not provided, result would be of the same type as X
113+
y = from_table(result.responses, like=classes if classes is not None else X)
214114
return y
215115

216-
def _predict_proba(self, X):
217-
result = self._infer(X)
218-
_, xp, _ = _get_sycl_namespace(X)
116+
@supports_queue
117+
def predict_proba(self, X, queue=None):
118+
result = self._infer(X, queue)
219119
y = from_table(result.probabilities, like=X)
220-
y = xp.reshape(y, -1)
221-
return xp.stack([1 - y, y], axis=1)
222-
223-
def _predict_log_proba(self, X):
224-
_, xp, _ = _get_sycl_namespace(X)
225-
y_proba = self._predict_proba(X)
226-
# These are the same thresholds used by oneDAL during the model fitting procedure
227-
if y_proba.dtype == np.float32:
228-
min_prob = 1e-7
229-
max_prob = 1.0 - 1e-7
230-
else:
231-
min_prob = 1e-15
232-
max_prob = 1.0 - 1e-15
233-
y_proba = xp.clip(y_proba, min_prob, max_prob)
234-
return xp.log(y_proba)
235-
236-
def _decision_function(self, X):
237-
_, xp, _ = _get_sycl_namespace(X)
238-
raw = xp.matmul(X, xp.reshape(self.coef_, -1))
239-
if self.fit_intercept:
240-
raw += self.intercept_
241-
return raw
242-
243-
244-
class LogisticRegression(ClassifierMixin, BaseLogisticRegression):
120+
return y
121+
122+
123+
class LogisticRegression(BaseLogisticRegression):
245124

246125
def __init__(
247126
self,
@@ -262,32 +141,3 @@ def __init__(
262141
max_iter=max_iter,
263142
algorithm=algorithm,
264143
)
265-
266-
@bind_default_backend("logistic_regression.classification")
267-
def train(self, params, X, y, queue=None): ...
268-
269-
@bind_default_backend("logistic_regression.classification")
270-
def infer(self, params, X, model, queue=None): ...
271-
272-
@bind_default_backend("logistic_regression.classification")
273-
def model(self): ...
274-
275-
@supports_queue
276-
def fit(self, X, y, queue=None):
277-
return self._fit(X, y)
278-
279-
@supports_queue
280-
def predict(self, X, queue=None):
281-
return self._predict(X)
282-
283-
@supports_queue
284-
def predict_proba(self, X, queue=None):
285-
return self._predict_proba(X)
286-
287-
@supports_queue
288-
def predict_log_proba(self, X, queue=None):
289-
return self._predict_log_proba(X)
290-
291-
@supports_queue
292-
def decision_function(self, X, queue=None):
293-
return self._decision_function(X)

onedal/spmd/linear_model/logistic_regression.py

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -32,13 +32,9 @@ def fit(self, X, y, queue=None):
3232
return super().fit(X, y, queue=queue)
3333

3434
@support_input_format
35-
def predict(self, X, queue=None):
36-
return super().predict(X, queue=queue)
35+
def predict(self, X, queue=None, classes=None):
36+
return super().predict(X, queue=queue, classes=classes)
3737

3838
@support_input_format
3939
def predict_proba(self, X, queue=None):
4040
return super().predict_proba(X, queue=queue)
41-
42-
@support_input_format
43-
def predict_log_proba(self, X, queue=None):
44-
return super().predict_log_proba(X, queue=queue)

0 commit comments

Comments
 (0)