Skip to content

WIP, ENH, TST: Array API support initial draft - #2

Open
eiviani-lanl wants to merge 1 commit into
extreme-learning-machinefrom
elm_array_api
Open

eiviani-lanl wants to merge 1 commit into
extreme-learning-machinefrom
elm_array_api

Conversation

@eiviani-lanl

Copy link
Copy Markdown
Owner

This is the first draft of Array API support. In making these changes, I had to take some artistic liberties with regard to partial_fit, as there aren't many good examples of sklearn estimators that have both partial_fit and Array API support.

I think we should also consider how closely we expect the NumPy and Torch paths to agree numerically, particularly for float32 inputs. I made some changes to the solvers in our models (Ridge(solver="svd") and storing A and B in float64) to improve consistency between the two paths, but I'm not sure if this is what users will expect. sklearn seems to accept the numerical differences produced by Ridge, so maybe these changes aren't strictly necessary.

@eiviani-lanl
eiviani-lanl changed the base branch from main to extreme-learning-machine October 15, 2026 02:07
@sdtemple

Copy link
Copy Markdown
Collaborator

Thanks for putting this initial draft up. We noticed you opened this during our group meeting and some of us (especially me) will start reviewing this.

@sdtemple sdtemple left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I did an initial pass of comments only while looking at the code. There are some notes on things I'll circle back to and verify if that code is necessary or could be edited.

Comment on lines +94 to +97
xp, _ = get_namespace(X)
tmp = -X + xp.min(X, axis=1)[:, xp.newaxis]
X[...] = xp.exp(tmp)
X /= xp.sum(X, axis=1)[:, xp.newaxis]

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can this just be return inplace_softmax(-X)? I guess perhaps it cannot because it is an in place call?

Comment thread sklearn/neural_network/_base.py
Comment on lines +68 to +69
xp, _ = get_namespace(X)
X[...] = xp.maximum(X, xp.zeros_like(X))

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I know at lanl/GFDL#130 that I used get_namespace_and_device so that we could put zeros on the device.

    xp, _, device = get_namespace_and_device(z)
    zero = xp.asarray(0, dtype=z.dtype, device=device)
    return xp.maximum(zero, z)

Need to check that there is not a lack of device issue

Comment on lines +861 to +868
@parametrize_with_checks(
estimators=[ExtremeLearningClassifier(), ExtremeLearningRegressor()],
expected_failed_checks=get_expected_failed_checks,
)
def test_sklearn_array_api_conformance(estimator, check):
# None of the sklearn models require this test, maybe we can pull it out?
with config_context(array_api_dispatch=True):
check(estimator)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe check(estimator) may already handle the with config_context(array_api_dispatch=True) for any array API tests when SCIPY_ARRAY_API=1 is turned on. This would negate the need to write the expected failure of _NotAnArray because it would only do that test for base NumPy.

I'll check the source code.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I actually think we can remove this entire test. I believe that check_array_api_input is captured in _yield_array_api_checks which is captured in _yield_checks. And within the array API related tests in sklearn.utils.estimator_checks they are repeatedly using with config_context(...): and turning those on when an estimator has the array API tags.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay, great.

ExtremeLearningClassifier(ridge_alpha=0.1),
ExtremeLearningRegressor(ridge_alpha=0.1),
],
)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

At sdtemple/GFDL (scikit-learn#130 of lanl/GFDL), I test a lot more combinations of weights, activations, and layer sizes.

I'll check the sklearn source code to see if that is necessary or if it is only checking input validation-related things.

Comment on lines +157 to +160
xp, _, device_ = get_namespace_and_device(
X,
y,
)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I started to drop the y argument to this function. I think the convention is that everything should be put on X namespace and device. I have to verify if adding y when y is a NumPy array and X is a torch tensor would result in xp as NumPy.

I have actually been hoping that people keep y on a NumPy array for downstream OneHotEncoder, though I think we should cast y to NumPy before OneHotEncoder anyway as a defensive mechanism.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you run the following, you'll see that NumPy is the xp brought in:

import torch
import numpy
from sklearn.utils._array_api import get_namespace
x = numpy.zeros(5)
y = torch.zeros(5)
get_namespace(x,y)

If you check the implementation in LogisticRegression, they only get the namespace from X.

self.coef_ = self.coef_.ravel()
else:
self.coef_ = _ravel(self.coef_)
return self

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is the ravel necessary?

# shape: (n_samples, n_features)
X, y = validate_data(self, X, y)
xp, _, device_ = get_namespace_and_device(X)
y_xp, _, _ = get_namespace_and_device(y)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have to check if this is strictly necessary.

self,
X,
y,
dtype=[xp.float64, xp.float32],

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have to check if this is strictly necessary.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This I believe achieves what I do with:

        if not xp.isdtype(X.dtype, "real floating"):
            default_float_dtype = _max_precision_float_dtype(xp, device)
            X = xp.astype(X, default_float_dtype)

When I delete the above from sdtemple/GFDL and put in your version of validate_data it works as desired. They don't always do that dtype=[xp.float64, xp.float32] with validate_data, but they do it in .fit() for logistic regression and the GMM, so let's keep it.

Comment on lines +554 to +559
# Keep classes_ in y's original namespace and device.
self.classes_ = y_xp.sort(y_xp.unique_values(y))

matches = y_xp.expand_dims(y, axis=1) == y_xp.expand_dims(
self.classes_, axis=0
)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have to check if this is strictly necessary.

# design matrix shape: (n_samples, sum_hidden+n_features)
# or (n_samples, sum_hidden)
D = self._design_matrix(X)
D = self._design_matrix(X, xp)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it'd be better practice to have these methods like _design_matrix read the xp from X instead of passing xp.

return self._predict_proba(X, xp)

def _predict_proba(self, X):
def _predict_proba(self, X, xp):

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The convention in @_logistic.py seems to be that you pass in only X and use get_namespace within the function.

@sdtemple sdtemple left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here are more comments about the test functions. I'll be making edits on my branch with respect to those comments.

# many fail otherwise
xp, device = _array_api_for_tests(array_namespace, device_name, dtype_name)
X_np, y_np = generator(
n_samples=10000,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you think we need these many samples? The sklearn utility, that we thought had too few, used 30. The logistic regression implementation uses iris data (which has 150? samples).

Comment on lines +625 to +628
assert get_namespace(pred_xp)[0] == get_namespace(X_xp)[0]
assert array_device(pred_xp) == array_device(X_xp)
assert pred_xp.dtype == X_xp.dtype

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you think these aren't already covered by the sklearn array API compliance checks?

assert_allclose(
move_to(attr_xp, xp=np, device="cpu"), attr_np, rtol=rtol, atol=atol
)
assert attr_xp.dtype == X_xp.dtype

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can systematically test this in a for loop. This is what sklearn does. Although that is a for loop only over "coef_", it would be good to follow their code patterns and its sustainable if other attributes get added.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

While I do evidence that test_logistic_regression_array_api_compliance checks the coefficients, they do this for 1 small dataset (iris). That's the brittle part of the test.

Comment on lines +838 to +844
check_array_api_input_and_values(
name,
estimator,
array_namespace,
device_name=device_name,
dtype_name=dtype_name,
)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I actually think we can remove this entire test. I believe that check_array_api_input is captured in _yield_array_api_checks which is captured in _yield_checks. And within the array API related tests in sklearn.utils.estimator_checks they are repeatedly using with config_context(...): and turning those on when an estimator has the array API tags.

Comment on lines +861 to +868
@parametrize_with_checks(
estimators=[ExtremeLearningClassifier(), ExtremeLearningRegressor()],
expected_failed_checks=get_expected_failed_checks,
)
def test_sklearn_array_api_conformance(estimator, check):
# None of the sklearn models require this test, maybe we can pull it out?
with config_context(array_api_dispatch=True):
check(estimator)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I actually think we can remove this entire test. I believe that check_array_api_input is captured in _yield_array_api_checks which is captured in _yield_checks. And within the array API related tests in sklearn.utils.estimator_checks they are repeatedly using with config_context(...): and turning those on when an estimator has the array API tags.

)


@parametrize_with_checks([ExtremeLearningClassifier(), ExtremeLearningRegressor()])

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it make sense to add a version of the estimators with the pinv turned on as well?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we can get by with just this test, which will cover 2 of your other tests, as long as we put a specific @skip_if_array_api_compat_not_configured decorator on the test function.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we call this conformance instead of compliance?

Comment thread sklearn/neural_network/tests/test_elm.py
Comment on lines +705 to +708
assert get_namespace(pred_xp)[0] == get_namespace(X_xp)[0]
assert array_device(pred_xp) == array_device(X_xp)
assert pred_xp.dtype == X_xp.dtype

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think these are captured in the array API compliance checks already.

Comment on lines +659 to +667
def test_array_api_activations(
activation,
weight_init,
estimator,
generator,
array_namespace,
device_name,
dtype_name,
):

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So, is this just a test that the activation functions behave with a smaller dataset? I feel like there is a lot of redundancy. And, don't the activations get used elsewhere in the neural_network module. We may need to consider that.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would say in this case we may want to synthesize some of the tests into 1.


@pytest.mark.parametrize("ridge_alpha", [None, 2])
@pytest.mark.parametrize("hidden_layer_sizes", [(10,), (10, 10)])
@pytest.mark.parametrize("batch", [100, 1000])

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't see much value in exploring 2 batch sizes

Comment on lines +241 to +246
self.A = xp.zeros(
(D.shape[1], D.shape[1]), dtype=xp.float64, device=device_
)
self.B = xp.zeros(
(D.shape[1], y.shape[1]), dtype=xp.float64, device=device_
)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These xp.zeros are problematic because you set dtype=xp.float64. This crashes for me when I run with PYTORCH_ENABLE_MPS_FALLBACK=1

Comment on lines +801 to +819
assert get_namespace(pred_xp)[0] == get_namespace(X_xp)[0]
assert array_device(pred_xp) == array_device(X_xp)
assert pred_xp.dtype == X_xp.dtype

assert_allclose(
move_to(pred_xp, xp=np, device="cpu"),
pred_np,
rtol=rtol,
atol=atol,
)

attr_xp = getattr(elm_xp, "coef_")
attr_np = getattr(elm_np, "coef_")

assert_allclose(
move_to(attr_xp, xp=np, device="cpu"), attr_np, rtol=rtol, atol=atol
)
assert attr_xp.dtype == X_xp.dtype
assert array_device(attr_xp) == array_device(X_xp)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These lines don't have to be in the with block for the array API dispatch. In general, only .fit(), .predict, and .partial_fit need to be. Also, some of these assert statements I think are already captured by the compliance check.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would suggest we remove the coefficient matching for partial_fit. There isn't much precedent for that.

self.coef_ = xp.astype(self.coef_, X.dtype)
if self.coef_.ndim == 2 and self.coef_.shape[1] == 1:
self.coef_ = self.coef_.ravel()
self.coef_ = _ravel(self.coef_)

@sdtemple sdtemple Oct 22, 2026 •

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Under this if guard, I think xp.squeeze(self.coef_, axis=1) achieves the same without using the private utility. I'll make that change in my branch, as I did in lanl/GFDL

Comment on lines 189 to +193
if self.coef_.ndim == 2 and self.coef_.shape[1] == 1:
self.coef_ = self.coef_.ravel()
if _is_numpy_namespace(xp):
self.coef_ = self.coef_.ravel()
else:
self.coef_ = _ravel(self.coef_)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Under this if guard, I think xp.squeeze(self.coef_, axis=1) achieves the same without using the private utility. I'll make this change in my branch, as I did in the lanl/GFDL PR

Comment on lines +648 to +660
if not hasattr(self, "classes_"):
if _is_numpy_namespace(y_xp):
classes = move_to(classes, xp=y_xp, device=y_device_)
classes = y_xp.asarray(classes, device=y_device_)
self._label_binarizer = LabelBinarizer().fit(classes)
self.classes_ = self._label_binarizer.classes_

else:
if y.ndim == 2 and y.shape[1] == 1:
y = y_xp.reshape(y, (-1,))
classes = move_to(classes, xp=y_xp, device=y_device_)
self.classes_ = y_xp.sort(y_xp.unique_values(classes))

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@eiviani-lanl Is there some reason related to backward compatibility why we have all these if and else blocks based on _is_numpy_namspace?

LabelBinarizer has Array API support. https://scikit-learn.org/stable/modules/array_api.html#estimators

It is possible we could write this more succinctly.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't see instances of the Naive Bayes estimator of LogisticRegression having these if and else blocks for handling NumPy label binarization differently. I would say we can use LabelBinarizer for all array namespaces, as this has the Array API support.

I proposed such a change on my branch. It is fewer code lines and clearer without if statements.

Comment on lines +626 to +633
W_xp, _, W_device_ = get_namespace_and_device(self.W_[0])

if (xp != W_xp) or (device_ != W_device_):
raise ValueError(
"All batches must use same device and namespace , %r, "
"but got %r." % ((W_xp, W_device_), (xp, device_))
)
dtype = self.W_[0].dtype

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is pretty verbose. However, the suggested edits from GPT Enterprise lead to default errors about namespace and device mismatches, which don't seem as informative as your custom ValueError here. I lean towards keeping it.

y = column_or_1d(y, warn=True)
check_classification_targets(y)

y_xp, _, y_device_ = get_namespace_and_device(y)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The convention from another Array API estimator like Naive Bayes is to use xp_y. That implies that this is the namespace of y, not y on some namespace xp. I made such naming changes for y, classes, and W

@sdtemple

Copy link
Copy Markdown
Collaborator

@eiviani-lanl I have gone through a few rounds now and I am satisfied with the initial review comments I gave. I made proposed code changes to address those comments at my branch elm_array_api_sdtemple. I do not want to merge my branch to this PR without your consent and your feedback on the edits. You can run

git diff elm_array_api..elm_array_api_sdtemple

to see how I primarily simplified you if blocks with _is_numpy_namespace, shortened redundant tests, and followed some naming convention aligned with logistic regression and Naive Bayes Array API estimators.

You can also use

git diff main..elm_array_api_sdtemple

to see the overall changes from the sklearn PR?

The main squabble I still have is with the partial fit function for the Array API support. It has a lot of duplicated code and can be brittle for atol and rtol values in float32. I do feel like partial fit is already covered pretty extensively in other tests for the NumPy namespace and the analogous Array API estimator GaussianNB with partial_fit does not check backend numerical precision of partial_fit.

sdtemple added a commit to sdtemple/GFDL that referenced this pull request Oct 23, 2026
* SI disclosure: I did not use SI, except
	I did use GPT 6.1 Sol when reviewing
	Emma's work

* There were some Array API styles that Emma used
	in her sklearn PR that I preferred over mine.
	I also identified some redundant tests in
	my sklearn PR review. Here I am porting over
	those suggested changes.

* eiviani-lanl/scikit-learn#2

* Use `xp.asarray` instead of `move_to` and `xp.astype`

* Use `dtype=[xp.float64, xp.float32]` in `validate_data`

* Only `xp.squeeze` when needed. Fixes a bug with classifier
	which will go upstream when I rebase lanl#130

* Removed redundant tests which were identified during review
assert array_device(pred_xp) == array_device(X_xp)
assert pred_xp.dtype == X_xp.dtype

assert_allclose(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

array-api-extra has a utility xpx.testing.assert_close that handles the dtype precision and also checks namespace and device. That package is vendored in scikit-learn. I think we should use it so that we don't manually set rtol.

I'll post an update to my branch.

):
xp, device = _array_api_for_tests(array_namespace, device_name, dtype_name)
X_np, y_np = generator(
n_samples=10000,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would suggest we do a very small dataset for partial_fit, e.g., 6 samples and a batch size of 2. There isn't much precedent for such a large dataset in partial_fit checks.

sdtemple added a commit to sdtemple/GFDL that referenced this pull request Oct 27, 2026
* SI disclosure: I did not use SI, except
	I did use GPT 6.1 Sol when reviewing
	Emma's work

* There were some Array API styles that Emma used
	in her sklearn PR that I preferred over mine.
	I also identified some redundant tests in
	my sklearn PR review. Here I am porting over
	those suggested changes.

* eiviani-lanl/scikit-learn#2

* Use `xp.asarray` instead of `move_to` and `xp.astype`

* Use `dtype=[xp.float64, xp.float32]` in `validate_data`

* Only `xp.squeeze` when needed. Fixes a bug with classifier
	which will go upstream when I rebase lanl#130

* Removed redundant tests which were identified during review
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants