Skip to content

Commit a481e87

Browse files
authored
MNT move _check_categorical_features to utils.validation (scikit-learn#33946)
1 parent ad19a84 commit a481e87

3 files changed

Lines changed: 199 additions & 112 deletions

File tree

sklearn/ensemble/_hist_gradient_boosting/gradient_boosting.py

Lines changed: 2 additions & 112 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@
1010
from numbers import Integral, Real
1111
from time import time
1212

13-
import narwhals.stable.v2 as nw
1413
import numpy as np
1514

1615
from sklearn._loss.loss import (
@@ -46,6 +45,7 @@
4645
from sklearn.utils._param_validation import Interval, RealNotInt, StrOptions
4746
from sklearn.utils.multiclass import check_classification_targets
4847
from sklearn.utils.validation import (
48+
_check_categorical_features,
4949
_check_monotonic_cst,
5050
_check_sample_weight,
5151
_check_y,
@@ -267,7 +267,7 @@ def _preprocess_X(self, X, *, reset):
267267
return self._preprocessor.transform(X)
268268

269269
# At this point, reset is False, which runs during `fit`.
270-
self.is_categorical_ = self._check_categorical_features(X)
270+
self.is_categorical_ = _check_categorical_features(X, self.categorical_features)
271271

272272
if self.is_categorical_ is None:
273273
self._preprocessor = None
@@ -353,116 +353,6 @@ def _check_categories(self):
353353
known_categories[feature_idx] = np.arange(len(categories), dtype=X_DTYPE)
354354
return known_categories
355355

356-
def _check_categorical_features(self, X):
357-
"""Check and validate categorical features in X
358-
359-
Parameters
360-
----------
361-
X : {array-like, pandas DataFrame} of shape (n_samples, n_features)
362-
Input data.
363-
364-
Return
365-
------
366-
is_categorical : ndarray of shape (n_features,) or None, dtype=bool
367-
Indicates whether a feature is categorical. If no feature is
368-
categorical, this is None.
369-
"""
370-
if nw.dependencies.is_into_dataframe(X):
371-
X = nw.from_native(X)
372-
dtypes = X.schema.dtypes()
373-
X_is_dataframe = True
374-
categorical_columns_mask = np.asarray(
375-
[d in (nw.Categorical, nw.Enum) for d in dtypes]
376-
)
377-
else:
378-
X_is_dataframe = False
379-
categorical_columns_mask = None
380-
381-
categorical_features = self.categorical_features
382-
383-
categorical_by_dtype = (
384-
isinstance(categorical_features, str)
385-
and categorical_features == "from_dtype"
386-
)
387-
no_categorical_dtype = categorical_features is None or (
388-
categorical_by_dtype and not X_is_dataframe
389-
)
390-
391-
if no_categorical_dtype:
392-
return None
393-
394-
if categorical_by_dtype and X_is_dataframe:
395-
categorical_features = categorical_columns_mask
396-
else:
397-
categorical_features = np.asarray(categorical_features)
398-
399-
if categorical_features.size == 0:
400-
return None
401-
402-
if categorical_features.dtype.kind not in ("i", "b", "U", "O"):
403-
raise ValueError(
404-
"categorical_features must be an array-like of bool, int or "
405-
f"str, got: {categorical_features.dtype.name}."
406-
)
407-
408-
if categorical_features.dtype.kind == "O":
409-
types = set(type(f) for f in categorical_features)
410-
if types != {str}:
411-
raise ValueError(
412-
"categorical_features must be an array-like of bool, int or "
413-
f"str, got: {', '.join(sorted(t.__name__ for t in types))}."
414-
)
415-
416-
n_features = X.shape[1]
417-
# At this point `validate_data` was not called yet because we use the original
418-
# dtypes to discover the categorical features. Thus `feature_names_in_`
419-
# is not defined yet.
420-
feature_names_in_ = getattr(X, "columns", None)
421-
422-
if categorical_features.dtype.kind in ("U", "O"):
423-
# check for feature names
424-
if feature_names_in_ is None:
425-
raise ValueError(
426-
"categorical_features should be passed as an array of "
427-
"integers or as a boolean mask when the model is fitted "
428-
"on data without feature names."
429-
)
430-
is_categorical = np.zeros(n_features, dtype=bool)
431-
feature_names = list(feature_names_in_)
432-
for feature_name in categorical_features:
433-
try:
434-
is_categorical[feature_names.index(feature_name)] = True
435-
except ValueError as e:
436-
raise ValueError(
437-
f"categorical_features has an item value '{feature_name}' "
438-
"which is not a valid feature name of the training "
439-
f"data. Observed feature names: {feature_names}"
440-
) from e
441-
elif categorical_features.dtype.kind == "i":
442-
# check for categorical features as indices
443-
if (
444-
np.max(categorical_features) >= n_features
445-
or np.min(categorical_features) < 0
446-
):
447-
raise ValueError(
448-
"categorical_features set as integer "
449-
"indices must be in [0, n_features - 1]"
450-
)
451-
is_categorical = np.zeros(n_features, dtype=bool)
452-
is_categorical[categorical_features] = True
453-
else:
454-
if categorical_features.shape[0] != n_features:
455-
raise ValueError(
456-
"categorical_features set as a boolean mask "
457-
"must have shape (n_features,), got: "
458-
f"{categorical_features.shape}"
459-
)
460-
is_categorical = categorical_features
461-
462-
if not np.any(is_categorical):
463-
return None
464-
return is_categorical
465-
466356
def _check_interaction_cst(self, n_features):
467357
"""Check and validation for interaction constraints."""
468358
if self.interaction_cst is None:

sklearn/utils/tests/test_validation.py

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@
6565
from sklearn.utils.validation import (
6666
FLOAT_DTYPES,
6767
_allclose_dense_sparse,
68+
_check_categorical_features,
6869
_check_feature_names_in,
6970
_check_method_params,
7071
_check_pos_label_consistency,
@@ -2439,3 +2440,76 @@ def test_check_array_on_sparse_inputs_with_array_api_enabled():
24392440
def test_check_array_allow_nd_errors(X, estimator, expected_error_message):
24402441
with pytest.raises(ValueError, match=expected_error_message):
24412442
check_array(X, estimator=estimator)
2443+
2444+
2445+
@pytest.mark.parametrize(
2446+
["categorical_features", "expected_msg"],
2447+
[
2448+
(
2449+
[b"hello", b"world"],
2450+
re.escape(
2451+
"categorical_features must be an array-like of bool, int or str, "
2452+
"got: bytes40."
2453+
),
2454+
),
2455+
(
2456+
np.array([b"hello", 1.3], dtype=object),
2457+
re.escape(
2458+
"categorical_features must be an array-like of bool, int or str, "
2459+
"got: bytes, float."
2460+
),
2461+
),
2462+
(
2463+
[0, -1],
2464+
re.escape(
2465+
"categorical_features set as integer indices must be in "
2466+
"[0, n_features - 1]"
2467+
),
2468+
),
2469+
(
2470+
[True, True, False, False, True],
2471+
re.escape(
2472+
"categorical_features set as a boolean mask must have shape "
2473+
"(n_features,)"
2474+
),
2475+
),
2476+
],
2477+
)
2478+
def test_check_categorical_features_raises(categorical_features, expected_msg):
2479+
"""Test that check_categorical_features raises expected errors."""
2480+
rng = np.random.RandomState(0)
2481+
n_samples, n_features = 10, 10
2482+
X = rng.randint(0, 3, size=(n_samples, n_features))
2483+
2484+
with pytest.raises(ValueError, match=expected_msg):
2485+
_check_categorical_features(X, categorical_features)
2486+
2487+
2488+
@pytest.mark.parametrize(
2489+
["categorical_features", "on_array"],
2490+
[
2491+
([False, True, True, False], True),
2492+
([1, 2], True),
2493+
(["b", "c"], False),
2494+
("from_dtype", False),
2495+
],
2496+
)
2497+
@pytest.mark.parametrize("constructor_name", ["array", "pandas", "polars"])
2498+
def test_check_categorical_features(categorical_features, on_array, constructor_name):
2499+
"""Test that check_categorical_features returns as expected on simple data."""
2500+
rng = np.random.RandomState(0)
2501+
n_samples, n_features = 30, 4
2502+
X = rng.randint(0, 3, size=(n_samples, n_features))
2503+
if constructor_name == "array" and not on_array:
2504+
return
2505+
elif constructor_name == "polars":
2506+
X = X.astype(str)
2507+
X = _convert_container(
2508+
X,
2509+
constructor_name,
2510+
columns_name=["a", "b", "c", "d"],
2511+
categorical_feature_names=["b", "c"],
2512+
)
2513+
2514+
result = _check_categorical_features(X, categorical_features)
2515+
assert_allclose(result, [False, True, True, False])

sklearn/utils/validation.py

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2468,6 +2468,129 @@ def _generate_get_feature_names_out(estimator, n_features_out, input_features=No
24682468
)
24692469

24702470

2471+
def _check_categorical_features(X, categorical_features):
2472+
"""Check and validate categorical features in X
2473+
2474+
Parameters
2475+
----------
2476+
X : {array-like, pandas DataFrame} of shape (n_samples, n_features)
2477+
Input data.
2478+
2479+
categorical_features : array-like of {bool, int, str} of shape (n_features) \
2480+
or shape (n_categorical_features,), default='from_dtype'
2481+
Indicates the categorical features in `X`.
2482+
2483+
- None : no feature will be considered categorical.
2484+
- boolean array-like : boolean mask indicating categorical features.
2485+
- integer array-like : integer indices indicating categorical
2486+
features.
2487+
- str array-like: names of categorical features (assuming the training
2488+
data has feature names).
2489+
- `"from_dtype"`: dataframe columns with dtype "Categorical" and "Enum" are
2490+
considered to be categorical features. The input must be a dataframe that
2491+
is supported by narwhals (or supports it): :func:`narwhals.from_native` must
2492+
work. This is the case, for instance, for pandas and polars DataFrames.
2493+
2494+
Return
2495+
------
2496+
is_categorical : ndarray of shape (n_features,) or None, dtype=bool
2497+
Indicates whether a feature is categorical. If no feature is
2498+
categorical, this is None.
2499+
"""
2500+
if nw.dependencies.is_into_dataframe(X):
2501+
X = nw.from_native(X)
2502+
dtypes = X.schema.dtypes()
2503+
X_is_dataframe = True
2504+
categorical_columns_mask = np.asarray(
2505+
[d in (nw.Categorical, nw.Enum) for d in dtypes]
2506+
)
2507+
else:
2508+
X_is_dataframe = False
2509+
categorical_columns_mask = None
2510+
2511+
categorical_by_dtype = (
2512+
isinstance(categorical_features, str) and categorical_features == "from_dtype"
2513+
)
2514+
no_categorical_dtype = categorical_features is None or (
2515+
categorical_by_dtype and not X_is_dataframe
2516+
)
2517+
2518+
if no_categorical_dtype:
2519+
return None
2520+
2521+
if categorical_by_dtype and X_is_dataframe:
2522+
categorical_features = categorical_columns_mask
2523+
else:
2524+
categorical_features = np.asarray(categorical_features)
2525+
2526+
if categorical_features.size == 0:
2527+
return None
2528+
2529+
if categorical_features.dtype.kind not in ("i", "b", "U", "O"):
2530+
raise ValueError(
2531+
"categorical_features must be an array-like of bool, int or "
2532+
f"str, got: {categorical_features.dtype.name}."
2533+
)
2534+
2535+
if categorical_features.dtype.kind == "O":
2536+
types = set(type(f) for f in categorical_features)
2537+
if types != {str}:
2538+
raise ValueError(
2539+
"categorical_features must be an array-like of bool, int or "
2540+
f"str, got: {', '.join(sorted(t.__name__ for t in types))}."
2541+
)
2542+
2543+
n_features = X.shape[1]
2544+
# At this point `validate_data` was not called yet because we use the original
2545+
# dtypes to discover the categorical features. Thus `feature_names_in_`
2546+
# is not defined yet.
2547+
feature_names_in_ = getattr(X, "columns", None)
2548+
2549+
if categorical_features.dtype.kind in ("U", "O"):
2550+
# check for feature names
2551+
if feature_names_in_ is None:
2552+
raise ValueError(
2553+
"categorical_features should be passed as an array of "
2554+
"integers or as a boolean mask when the model is fitted "
2555+
"on data without feature names."
2556+
)
2557+
is_categorical = np.zeros(n_features, dtype=bool)
2558+
feature_names = list(feature_names_in_)
2559+
for feature_name in categorical_features:
2560+
try:
2561+
is_categorical[feature_names.index(feature_name)] = True
2562+
except ValueError as e:
2563+
raise ValueError(
2564+
f"categorical_features has an item value '{feature_name}' "
2565+
"which is not a valid feature name of the training "
2566+
f"data. Observed feature names: {feature_names}"
2567+
) from e
2568+
elif categorical_features.dtype.kind == "i":
2569+
# check for categorical features as indices
2570+
if (
2571+
np.max(categorical_features) >= n_features
2572+
or np.min(categorical_features) < 0
2573+
):
2574+
raise ValueError(
2575+
"categorical_features set as integer "
2576+
"indices must be in [0, n_features - 1]"
2577+
)
2578+
is_categorical = np.zeros(n_features, dtype=bool)
2579+
is_categorical[categorical_features] = True
2580+
else:
2581+
if categorical_features.shape[0] != n_features:
2582+
raise ValueError(
2583+
"categorical_features set as a boolean mask "
2584+
"must have shape (n_features,), got: "
2585+
f"{categorical_features.shape}"
2586+
)
2587+
is_categorical = categorical_features
2588+
2589+
if not np.any(is_categorical):
2590+
return None
2591+
return is_categorical
2592+
2593+
24712594
def _check_monotonic_cst(estimator, monotonic_cst=None):
24722595
"""Check the monotonic constraints and return the corresponding array.
24732596

0 commit comments

Comments
 (0)