Skip to content

Commit d1f1734

Browse files
lucyleeowogriselOmarManzoor
authored
Add common test for mixed y string and array API X for estimators (scikit-learn#34142)
Co-authored-by: Olivier Grisel <olivier.grisel@ensta.org> Co-authored-by: Omar Salman <omar.salman@arbisoft.com>
1 parent 3c5db73 commit d1f1734

3 files changed

Lines changed: 165 additions & 44 deletions

File tree

doc/modules/array_api.rst

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,9 @@ into NumPy arrays using :func:`numpy.asarray` (or :func:`numpy.array`).
8989
While this will successfully convert some array API inputs (e.g., JAX array),
9090
we generally recommend setting `array_api_dispatch=True` when using array API inputs.
9191
This is because NumPy conversion can often fail, e.g., torch tensor allocated on GPU.
92+
Also note that the output array will be NumPy when `array_api_dispatch=False` whereas
93+
when `array_api_dispatch=True` the output array will depend on the input array (see
94+
:ref:`input_output_array_api` for details).
9295

9396
Example usage
9497
=============
@@ -260,18 +263,33 @@ Tools
260263
- :func:`model_selection.train_test_split`
261264
- :func:`utils.check_consistent_length`
262265

266+
.. _input_output_array_api:
267+
263268
Input and output array type handling
264269
====================================
265270

266271
Estimators and scoring functions are able to accept input arrays
267272
from different array libraries and/or devices. When a mixed set of input arrays is
268-
passed, scikit-learn converts arrays as needed to make them all consistent.
273+
passed, scikit-learn converts arrays as needed to make them all consistent. The
274+
following section describes how input and output array types are handled
275+
when `array_api_dispatch=True`.
269276

270277
For estimators, the rule is **"everything follows** `X` **"** - mixed array inputs are
271278
converted so that they all match the array library and device of `X`.
272279
For scoring functions the rule is **"everything follows** `y_pred` **"** - mixed array
273280
inputs are converted so that they all match the array library and device of `y_pred`.
274281

282+
Mixed array input support also covers the particular case where `y` is a NumPy
283+
array of string values, while other inputs are numerical arrays of any
284+
container type. As all models require numerical input, scikit-learn often converts
285+
`y` to a numerical representation internally (for instance via one-hot encoding or
286+
ordinal encoding). The result of this is able to be moved to the namespace and device
287+
of the other inputs, as string-valued arrays are not covered by the array API
288+
specifications.
289+
290+
Note that the `classes_` estimator attribute always remains in the same array
291+
library as the original `y`, in order to support string class labels.
292+
275293
When a function or method has been called with array API compatible inputs, the
276294
convention is to return arrays from the same array library and on the same
277295
device as the input data.

sklearn/utils/_test_common/instance_generator.py

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -951,9 +951,6 @@ def _yield_instances_for_check(check, estimator_orig):
951951
"sample_weight is not equivalent to removing/repeating samples."
952952
),
953953
},
954-
CalibratedClassifierCV: {
955-
"check_array_api_mixed_inputs": "mixed array API input support not added yet",
956-
},
957954
ColumnTransformer: {
958955
"check_estimators_empty_data_messages": "FIXME",
959956
"check_estimators_nan_inf": "FIXME",
@@ -987,11 +984,11 @@ def _yield_instances_for_check(check, estimator_orig):
987984
"sample_weight is not equivalent to removing/repeating samples."
988985
),
989986
},
990-
GaussianMixture: {
991-
"check_array_api_mixed_inputs": "mixed array API input support not added yet",
992-
},
993987
GaussianNB: {
994-
"check_array_api_mixed_inputs": "mixed array API input support not added yet",
988+
# TODO: Remove once fixed: https://github.com/pytorch/pytorch/issues/188128
989+
"check_array_api_mixed_inputs": (
990+
"PyTorch bug when asarray used on array-api-strict boolean array"
991+
),
995992
"check_array_api_same_namespace": "check_same_namespace not yet added",
996993
},
997994
GradientBoostingClassifier: {
@@ -1018,6 +1015,9 @@ def _yield_instances_for_check(check, estimator_orig):
10181015
},
10191016
HalvingGridSearchCV: {
10201017
"check_array_api_mixed_inputs": "mixed array API input support not added yet",
1018+
"check_array_api_string_and_numeric_inputs": (
1019+
"mixed string and numeric array API input support not added yet"
1020+
),
10211021
"check_fit2d_1sample": (
10221022
"Fail during parameter check since min/max resources requires more samples"
10231023
),
@@ -1029,6 +1029,9 @@ def _yield_instances_for_check(check, estimator_orig):
10291029
},
10301030
HalvingRandomSearchCV: {
10311031
"check_array_api_mixed_inputs": "mixed array API input support not added yet",
1032+
"check_array_api_string_and_numeric_inputs": (
1033+
"mixed string and numeric array API input support not added yet"
1034+
),
10321035
"check_fit2d_1sample": (
10331036
"Fail during parameter check since min/max resources requires more samples"
10341037
),
@@ -1087,6 +1090,9 @@ def _yield_instances_for_check(check, estimator_orig):
10871090
},
10881091
LinearDiscriminantAnalysis: {
10891092
"check_array_api_mixed_inputs": "mixed array API input support not added yet",
1093+
"check_array_api_string_and_numeric_inputs": (
1094+
"mixed string and numeric array API input support not added yet"
1095+
),
10901096
},
10911097
LabelEncoder: {
10921098
"check_array_api_same_namespace": "check_same_namespace not yet added",
@@ -1177,6 +1183,9 @@ def _yield_instances_for_check(check, estimator_orig):
11771183
},
11781184
PCA: {
11791185
"check_array_api_mixed_inputs": "mixed array API input support not added yet",
1186+
"check_array_api_string_and_numeric_inputs": (
1187+
"mixed string and numeric array API input support not added yet"
1188+
),
11801189
# TODO: see gh-33205 for details
11811190
"check_array_api_input": "`linalg.inv` fails because input is singular",
11821191
"check_array_api_same_namespace": "check_same_namespace not yet added",
@@ -1202,6 +1211,9 @@ def _yield_instances_for_check(check, estimator_orig):
12021211
},
12031212
PoissonRegressor: {
12041213
"check_array_api_mixed_inputs": "mixed array API input support not added yet",
1214+
"check_array_api_string_and_numeric_inputs": (
1215+
"mixed string and numeric array API input support not added yet"
1216+
),
12051217
"check_array_api_same_namespace": "check_same_namespace not yet added",
12061218
},
12071219
PolynomialFeatures: {

sklearn/utils/estimator_checks.py

Lines changed: 127 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -384,7 +384,20 @@ def _yield_array_api_checks(estimator, only_numpy=False):
384384
other_ns_and_device=other_ns_and_device,
385385
X_ns_and_device=X_ns_and_device,
386386
)
387-
# 3. Namespace/device consistency between fit and predict/transform
387+
# 3. String `y` and numeric `X` from all supported namespace/devices
388+
if is_classifier(estimator):
389+
for (
390+
array_namespace,
391+
device_name,
392+
dtype_name,
393+
) in yield_namespace_device_dtype_combinations():
394+
yield partial(
395+
check_array_api_string_and_numeric_inputs,
396+
array_namespace=array_namespace,
397+
device_name=device_name,
398+
dtype_name=dtype_name,
399+
)
400+
# 4. Namespace/device consistency between fit and predict/transform
388401
# Only test with one namespace to keep costs down
389402
# There should be no dependency on the exact namespace used.
390403
yield partial(
@@ -1098,16 +1111,12 @@ def _check_array_api_core(
10981111
xp_X, device_X = _array_api_for_tests(
10991112
X_ns_and_device.xp, X_ns_and_device.device, dtype_name
11001113
)
1101-
xp_other, device_other = _array_api_for_tests(
1102-
other_ns_and_device.xp, other_ns_and_device.device
1103-
)
11041114

11051115
X, y = make_classification(n_samples=30, n_features=10, random_state=42)
11061116
if dtype_name is None:
11071117
max_float_dtype = _max_precision_float_dtype(xp_X, device_X)
11081118
# Convert to string, so it is accepted by NumPy (`X` is NumPy array)
11091119
dtype_name = "float32" if max_float_dtype == xp_X.float32 else "float64"
1110-
11111120
X = X.astype(dtype_name, copy=False)
11121121

11131122
X = _enforce_estimator_tags_X(estimator_orig, X)
@@ -1117,7 +1126,17 @@ def _check_array_api_core(
11171126
set_random_state(est)
11181127

11191128
X_xp = xp_X.asarray(X, device=device_X)
1120-
y_xp = xp_other.asarray(y, device=device_other)
1129+
1130+
if other_ns_and_device == "string":
1131+
xp_other, device_other = _array_api_for_tests("numpy", "cpu")
1132+
# Convert binary `y` to string
1133+
y_xp = np.array(["a", "b"])[y]
1134+
y = y_xp
1135+
else:
1136+
xp_other, device_other = _array_api_for_tests(
1137+
other_ns_and_device.xp, other_ns_and_device.device
1138+
)
1139+
y_xp = xp_other.asarray(y, device=device_other)
11211140

11221141
fit_kwargs = {}
11231142
fit_kwargs_xp = {}
@@ -1136,48 +1155,50 @@ def _check_array_api_core(
11361155
est_xp.fit(X_xp, y_xp, **fit_kwargs_xp)
11371156

11381157
X_ns = xp_X.__name__
1158+
y_ns = xp_other.__name__
11391159

11401160
array_attributes = {
11411161
key: value for key, value in vars(est).items() if isinstance(value, np.ndarray)
11421162
}
11431163

11441164
# Fitted attributes which are arrays must have the same namespace as `X`,
11451165
# except `classes_`, to allow it to be string when `y` is string.
1146-
for key, attribute in array_attributes.items():
1147-
est_xp_param = getattr(est_xp, key)
1148-
with config_context(array_api_dispatch=True):
1149-
attribute_ns = get_namespace(est_xp_param)[0].__name__
1150-
if key != "classes_":
1151-
assert attribute_ns == X_ns, (
1152-
f"'{key}' attribute is in wrong namespace, expected {X_ns} "
1153-
f"got {attribute_ns}"
1154-
)
1155-
1166+
for attribute_name, attribute_value in array_attributes.items():
1167+
est_xp_attr = getattr(est_xp, attribute_name)
1168+
# `classes_` should be in same ns and device as `y`
1169+
expected_xp, expected_ns = (
1170+
(y_xp, y_ns) if attribute_name == "classes_" else (X_xp, X_ns)
1171+
)
11561172
with config_context(array_api_dispatch=True):
1157-
if key != "classes_":
1158-
assert array_device(est_xp_param) == array_device(X_xp)
1173+
attribute_ns = get_namespace(est_xp_attr)[0].__name__
1174+
assert array_device(est_xp_attr) == array_device(expected_xp)
1175+
assert attribute_ns == expected_ns, (
1176+
f"'{attribute_name}' attribute is in wrong namespace, expected "
1177+
f"{expected_ns} got {attribute_ns}"
1178+
)
11591179

1160-
est_xp_param_np = move_to(est_xp_param, xp=np, device="cpu")
1180+
est_xp_attr_np = move_to(est_xp_attr, xp=np, device="cpu")
11611181
if check_values:
11621182
assert_allclose(
1163-
attribute,
1164-
est_xp_param_np,
1165-
err_msg=f"{key} not the same",
1183+
attribute_value,
1184+
est_xp_attr_np,
1185+
err_msg=f"{attribute_name} not the same",
11661186
atol=_atol_for_type(X.dtype),
11671187
)
11681188
else:
1169-
assert attribute.shape == est_xp_param_np.shape
1170-
expected_dtype = attribute.dtype
1171-
if np.issubdtype(attribute.dtype, np.floating):
1172-
max_float_dtype = _max_precision_float_dtype(
1173-
xp_X, device=X_ns_and_device.device
1174-
)
1175-
# for some devices the maximum supported floating dtype is float32
1176-
if max_float_dtype == xp_X.float32:
1177-
expected_dtype = np.float32
1178-
assert est_xp_param_np.dtype == expected_dtype
1189+
assert attribute_value.shape == est_xp_attr_np.shape
1190+
if attribute_name != "classes_":
1191+
expected_dtype = attribute_value.dtype
1192+
if np.issubdtype(attribute_value.dtype, np.floating):
1193+
max_float_dtype = _max_precision_float_dtype(
1194+
xp_X, device=X_ns_and_device.device
1195+
)
1196+
# for some devices the maximum supported floating dtype is float32
1197+
if max_float_dtype == xp_X.float32:
1198+
expected_dtype = np.float32
1199+
assert est_xp_attr_np.dtype == expected_dtype
11791200

1180-
# Check estimator methods, if supported, give the same results
1201+
# Check supported estimator methods give the same results
11811202
methods = (
11821203
"score",
11831204
"score_samples",
@@ -1245,14 +1266,22 @@ def _check_array_api_core(
12451266

12461267
with config_context(array_api_dispatch=True):
12471268
result_ns = get_namespace(result_xp)[0].__name__
1248-
assert result_ns == X_ns, (
1249-
f"'{method}' output is in wrong namespace, expected {X_ns}, "
1269+
# `predict` would be string when `y` is string
1270+
expected_ns = X_ns
1271+
if other_ns_and_device == "string" and method_name == "predict":
1272+
expected_ns = y_ns
1273+
assert result_ns == expected_ns, (
1274+
f"'{method}' output is in wrong namespace, expected {expected_ns}, "
12501275
f"got {result_ns}."
12511276
)
12521277

12531278
if expect_only_array_outputs:
1279+
# `predict` would be string and on same device as `y`
1280+
expected_xp = X_xp
1281+
if other_ns_and_device == "string" and method_name == "predict":
1282+
expected_xp = y_xp
12541283
with config_context(array_api_dispatch=True):
1255-
assert array_device(result_xp) == array_device(X_xp)
1284+
assert array_device(result_xp) == array_device(expected_xp)
12561285

12571286
result_xp_np = move_to(result_xp, xp=np, device="cpu")
12581287
if check_values:
@@ -1441,6 +1470,68 @@ def check_array_api_mixed_inputs(
14411470
)
14421471

14431472

1473+
def check_array_api_string_and_numeric_inputs(
1474+
name,
1475+
estimator_orig,
1476+
array_namespace,
1477+
device_name=None,
1478+
dtype_name=None,
1479+
check_values=False,
1480+
expect_only_array_outputs=True,
1481+
):
1482+
"""Check `estimator_orig` works with string `y` and array API `X`.
1483+
1484+
For this check `y` is always a NumPy array of strings and `X` is an array
1485+
with namespace and device combinations generated by
1486+
`yield_namespace_device_dtype_combinations`.
1487+
1488+
Note `sample_weight` is never checked.
1489+
1490+
See `check_array_api_input` and `check_array_api_mixed_inputs` for similar
1491+
tests.
1492+
1493+
Parameters
1494+
----------
1495+
name : str
1496+
The name of the estimator. Used in error messages but ignored here.
1497+
1498+
estimator_orig : estimator
1499+
Original (uncloned) estimator instance.
1500+
1501+
array_namespace : str
1502+
The name of the Array API namespace of all estimator inputs.
1503+
1504+
device_name : str, default=None
1505+
The name of the device on which to allocate the estimator input arrays.
1506+
1507+
dtype_name : str, default=None
1508+
The name of the data type to use for arrays. If `None`,
1509+
`_max_precision_float_dtype` of namespace and device of
1510+
`X_ns_and_device` is used.
1511+
1512+
check_values : bool, default=False
1513+
Whether to check the values of attributes, method outputs (including
1514+
`inverse_transform`) obtained with array API inputs match that of all-NumPy
1515+
inputs. If `False` only the namespace, device, shape and dtype of attributes
1516+
and method outputs are checked.
1517+
1518+
expect_only_array_outputs : bool, default=True
1519+
Whether to expect non-array outputs such as sparse data structures and lists.
1520+
If `False` the checks are looser; device, shape and dtype checks for method
1521+
outputs are skipped and only a smoke test is performed for `inverse_transform`.
1522+
"""
1523+
X_ns_and_device = NamespaceAndDevice(array_namespace, device_name)
1524+
_check_array_api_core(
1525+
estimator_orig,
1526+
X_ns_and_device=X_ns_and_device,
1527+
other_ns_and_device="string",
1528+
dtype_name=dtype_name,
1529+
check_values=check_values,
1530+
check_sample_weight=False,
1531+
expect_only_array_outputs=expect_only_array_outputs,
1532+
)
1533+
1534+
14441535
def check_array_api_same_namespace(
14451536
name, estimator_orig, array_namespace, device_name=None
14461537
):

0 commit comments

Comments
 (0)