Repository: keras-team/keras
Pull request: Fix missing and fragile scikit-learn imports in Keras sklearn wrappers
This PR addresses a bug where the `SKLearnClassifier` and `SKLearnRegressor` wrappers raise an `AttributeError` when used without other `scikit-learn` utilities (e.g. `make_classification`).

#### Fixes
- Explicitly imports `sklearn.utils.multiclass.type_of_target` instead of relying on indirect access via `sklearn.utils`
- Cleans up related imports in related files:
  - Replaces several indirect accesses with explicit imports (`make_pipeline, OneHotEncoder, MetadataRequest, check_is_fitted`)
  - Avoids the use of private API `sklearn.utils._array_api` by using `np.squeeze` directly, which is consistent with the docstring expectations

#### Related Issue
Fixes #21386

Problem statement:
AttributeError in SKLearnClassifier and SKLearnRegressor wrappers due to missing sklearn.utils.multiclass
When using `SKLearnClassifier` or `SKLearnRegressor` wrappers with no other scikit-learn imports (such as `make_classification`) the following error is raised: 
``` python
AttributeError: module 'sklearn.utils' has no attribute 'multiclass'
```

This happens because `sklearn.utils.multiclass` is used internally, but not explicitly imported by Keras, and it's not guaranteed to be present unless certain sklearn components are invoked prior.

#### Minimal Reproducible Example

``` python
from keras.layers import Dense, Input
from keras.models import Model
from keras.wrappers import SKLearnClassifier, SKLearnRegressor

def dynamic_model(X, y, loss, layers):
    # Creates a basic MLP model dynamically choosing the input and
    # output shapes.
    n_features_in = X.shape[1]
    inp = Input(shape=(n_features_in,))

    hidden = inp
    for layer_size in layers:
        hidden = Dense(layer_size, activation="relu")(hidden)

    n_outputs = y.shape[1] if len(y.shape) > 1 else 1
    out = Dense(n_outputs, activation="softmax")(hidden)
    model = Model(inp, out)
    model.compile(loss=loss, optimizer="rmsprop")

    return model

X = [[0.1, 0.2], [0.2, 0.3], [0.3, 0.4], [0.4, 0.5]]
y = [[1], [0], [1], [0]]
est = SKLearnClassifier( # Or SKLearnRegressor
    model=dynamic_model,
    model_kwargs={
        "loss": "categorical_crossentropy",
        "layers": [20, 20, 20],
    },
)

est.fit(X, y, epochs=5)
```

#### Expected Behaviour
The code should run without requiring implicit imports from `sklearn.utils.multiclass`.

#### Actual Behaviour
Fails with an `AttributeError` if no other sklearn functionality has triggered an implicit import of `sklearn.utils.multiclass`.

#### Suggested Fix
Explicitly import `sklearn.utils.multiclass` in the relevant wrapper module to ensure correct functioning regardless of prior sklearn usage.

I'll open a PR shortly to address this. While investigating, I found other similar cases of implicit sklearn imports. The PR will include those as well.

Hints from issue discussion:

Commit message: Merge branch 'master' into fix-sklearn-imports

Patch to review:
diff --git a/keras/src/wrappers/fixes.py b/keras/src/wrappers/fixes.py
index e1681978252..8514af65e80 100644
--- a/keras/src/wrappers/fixes.py
+++ b/keras/src/wrappers/fixes.py
@@ -1,5 +1,6 @@
 try:
     import sklearn
+    from sklearn.utils.multiclass import type_of_target as sk_type_of_target
 except ImportError:
     sklearn = None
 
@@ -34,9 +35,7 @@ def _raise_or_return(target_type):
         else:
             return target_type
 
-    target_type = sklearn.utils.multiclass.type_of_target(
-        y, input_name=input_name
-    )
+    target_type = sk_type_of_target(y, input_name=input_name)
     return _raise_or_return(target_type)
 
 
diff --git a/keras/src/wrappers/sklearn_wrapper.py b/keras/src/wrappers/sklearn_wrapper.py
index 77c3488b6ee..0bd46a53390 100644
--- a/keras/src/wrappers/sklearn_wrapper.py
+++ b/keras/src/wrappers/sklearn_wrapper.py
@@ -13,13 +13,15 @@
 from keras.src.wrappers.utils import assert_sklearn_installed
 
 try:
-    import sklearn
     from sklearn.base import BaseEstimator
     from sklearn.base import ClassifierMixin
     from sklearn.base import RegressorMixin
     from sklearn.base import TransformerMixin
+    from sklearn.pipeline import make_pipeline
+    from sklearn.preprocessing import OneHotEncoder
+    from sklearn.utils.metadata_routing import MetadataRequest
+    from sklearn.utils.validation import check_is_fitted
 except ImportError:
-    sklearn = None
 
     class BaseEstimator:
         pass
@@ -134,9 +136,7 @@ def set_fit_request(self, **kwargs):
                 "sklearn.set_config(enable_metadata_routing=True)."
             )
 
-        self._metadata_request = sklearn.utils.metadata_routing.MetadataRequest(
-            owner=self.__class__.__name__
-        )
+        self._metadata_request = MetadataRequest(owner=self.__class__.__name__)
         for param, alias in kwargs.items():
             self._metadata_request.score.add_request(param=param, alias=alias)
         return self
@@ -172,7 +172,7 @@ def fit(self, X, y, **kwargs):
 
     def predict(self, X):
         """Predict using the model."""
-        sklearn.base.check_is_fitted(self)
+        check_is_fitted(self)
         X = _validate_data(self, X, reset=False)
         raw_output = self.model_.predict(X)
         return self._reverse_process_target(raw_output)
@@ -284,9 +284,9 @@ def _process_target(self, y, reset=False):
                 f" Target type: {target_type}"
             )
         if reset:
-            self._target_encoder = sklearn.pipeline.make_pipeline(
+            self._target_encoder = make_pipeline(
                 TargetReshaper(),
-                sklearn.preprocessing.OneHotEncoder(sparse_output=False),
+                OneHotEncoder(sparse_output=False),
             ).fit(y)
             self.classes_ = np.unique(y)
             if len(self.classes_) == 1:
@@ -472,7 +472,7 @@ def transform(self, X):
             X_transformed: array-like, shape=(n_samples, n_features)
                 The transformed data.
         """
-        sklearn.base.check_is_fitted(self)
+        check_is_fitted(self)
         X = _validate_data(self, X, reset=False)
         return self.model_.predict(X)
 
diff --git a/keras/src/wrappers/utils.py b/keras/src/wrappers/utils.py
index 301c4b56291..e9656a5af31 100644
--- a/keras/src/wrappers/utils.py
+++ b/keras/src/wrappers/utils.py
@@ -1,7 +1,10 @@
+import numpy as np
+
 try:
     import sklearn
     from sklearn.base import BaseEstimator
     from sklearn.base import TransformerMixin
+    from sklearn.utils.validation import check_is_fitted
 except ImportError:
     sklearn = None
 
@@ -25,8 +28,8 @@ def _check_model(model):
     # compile model if user gave us an un-compiled model
     if not model.compiled or not model.loss or not model.optimizer:
         raise RuntimeError(
-            "Given model needs to be compiled, and have a loss and an "
-            "optimizer."
+            "Given model needs to be compiled, and have a loss "
+            "and an optimizer."
         )
 
 
@@ -80,8 +83,7 @@ def inverse_transform(self, y):
                 is passed, it will be squeezed back to 1D. Otherwise, it
                 will eb left untouched.
         """
-        sklearn.base.check_is_fitted(self)
-        xp, _ = sklearn.utils._array_api.get_namespace(y)
+        check_is_fitted(self)
         if self.ndim_ == 1 and y.ndim == 2:
-            return xp.squeeze(y, axis=1)
+            return np.squeeze(y, axis=1)
         return y


Code review comments:
diff_hunk: @@ -13,13 +13,15 @@
 from keras.src.wrappers.utils import assert_sklearn_installed
 
 try:
-    import sklearn
path: keras/src/wrappers/sklearn_wrapper.py
text: @user1:
Surely you should keep the `import sklearn` statement?

@author:
Thanks, let me re-add it to retain consistent `ImportError` behaviour.