Repository navigation
WIP, ENH, TST: Array API support initial draft - #2
eiviani-lanl wants to merge 1 commit into
Conversation
|
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
left a comment
There was a problem hiding this comment.
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.
| 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] |
There was a problem hiding this comment.
Can this just be return inplace_softmax(-X)? I guess perhaps it cannot because it is an in place call?
| xp, _ = get_namespace(X) | ||
| X[...] = xp.maximum(X, xp.zeros_like(X)) |
There was a problem hiding this comment.
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
| @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) |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
| ExtremeLearningClassifier(ridge_alpha=0.1), | ||
| ExtremeLearningRegressor(ridge_alpha=0.1), | ||
| ], | ||
| ) |
There was a problem hiding this comment.
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.
| xp, _, device_ = get_namespace_and_device( | ||
| X, | ||
| y, | ||
| ) |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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 |
| # 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) |
There was a problem hiding this comment.
I have to check if this is strictly necessary.
| self, | ||
| X, | ||
| y, | ||
| dtype=[xp.float64, xp.float32], |
There was a problem hiding this comment.
I have to check if this is strictly necessary.
There was a problem hiding this comment.
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.
| # 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 | ||
| ) |
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
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): |
There was a problem hiding this comment.
The convention in @_logistic.py seems to be that you pass in only X and use get_namespace within the function.
sdtemple
left a comment
There was a problem hiding this comment.
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, |
There was a problem hiding this comment.
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).
| 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 | ||
|
|
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
| check_array_api_input_and_values( | ||
| name, | ||
| estimator, | ||
| array_namespace, | ||
| device_name=device_name, | ||
| dtype_name=dtype_name, | ||
| ) |
There was a problem hiding this comment.
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( | ||
| 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) |
There was a problem hiding this comment.
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()]) |
There was a problem hiding this comment.
Would it make sense to add a version of the estimators with the pinv turned on as well?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Why do we call this conformance instead of compliance?
| 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 | ||
|
|
There was a problem hiding this comment.
I think these are captured in the array API compliance checks already.
| def test_array_api_activations( | ||
| activation, | ||
| weight_init, | ||
| estimator, | ||
| generator, | ||
| array_namespace, | ||
| device_name, | ||
| dtype_name, | ||
| ): |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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]) |
There was a problem hiding this comment.
I don't see much value in exploring 2 batch sizes
| 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_ | ||
| ) |
There was a problem hiding this comment.
These xp.zeros are problematic because you set dtype=xp.float64. This crashes for me when I run with PYTORCH_ENABLE_MPS_FALLBACK=1
| 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) |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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_) |
There was a problem hiding this comment.
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
| 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_) |
There was a problem hiding this comment.
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
| 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)) | ||
|
|
There was a problem hiding this comment.
@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.
There was a problem hiding this comment.
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.
| 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 |
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
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
|
@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 git diff elm_array_api..elm_array_api_sdtempleto see how I primarily simplified you You can also use git diff main..elm_array_api_sdtempleto see the overall changes from the 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 |
* 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( |
There was a problem hiding this comment.
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, |
There was a problem hiding this comment.
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.
* 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
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 ofsklearnestimators that have bothpartial_fitand 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 storingAandBin float64) to improve consistency between the two paths, but I'm not sure if this is what users will expect.sklearnseems to accept the numerical differences produced byRidge, so maybe these changes aren't strictly necessary.