Repository: scikit-learn/scikit-learn
Pull request: FIX Handles all numerical DataFrames with check_inverse=True in FunctionTransformer
<!--
Thanks for contributing a pull request! Please ensure you have taken a look at
the contribution guidelines: https://github.com/scikit-learn/scikit-learn/blob/main/CONTRIBUTING.md
-->

#### Reference Issues/PRs
<!--
Example: Fixes #1234. See also #3456.
Please use keywords (e.g., Fixes) to create link to the issues or pull requests
you resolved, so that they will automatically be closed when your pull request
is merged. See https://github.com/blog/1506-closing-issues-via-pull-requests
-->
Fixes #25261
Closes #19905

#### What does this implement/fix? Explain your changes.
This PR explicitly handles dataframes  when validating the input when `check_inverse=True`.

#### Any other comments?
This closes in #19905, because it explicity raises an error for DataFrames `FunctionTransformer` does not know how to validate. 

<!--
Please be aware that we are a loose team of volunteers so patience is
necessary; assistance handling other issues is very welcome. We value
all user contributions, no matter how minor they are. If we are slow to
review, either the pull request needs some benchmarking, tinkering,
convincing, etc. or more likely the reviewers are simply busy. In either
case, we ask for your understanding during the review process.
For more information, see our FAQ on this topic:
http://scikit-learn.org/dev/faq.html#why-is-my-pull-request-not-getting-any-attention.

Thanks for contributing!
-->


Problem statement:
FunctionTransformer does not handle object dtype when check_inverse is True
#### Describe the bug

FunctionTransformer can process a feature made out of strings in a DataFrame. However, one has to disable the inverse_transform checking otherwise np.allclose is called on the dataframes, causing a crash.

I believe that the behavior should be consistent: either string DataFrames are not supported, and it should crash, or they are. It should not depend on the value of check_inverse.

#### Steps/Code to Reproduce

Example:
```python
from sklearn.preprocessing import FunctionTransformer
import pandas as pd
import numpy as np


mapping = {'uno': 1, 'dos':2, 'quattro':4}
inv_mapping = {1: 'uno', 2:'dos', 4:'quattro'}

data = pd.DataFrame({'value': ['uno', 'dos', 'quattro', 'uno', 'uno']})

transformer = FunctionTransformer(
    lambda X: np.array([mapping[x] for x in X['value']]),
    lambda X: pd.DataFrame({'value': np.array([inv_mapping[x] for x in X])}),
validate=False, check_inverse=False)

print("It works:")
print(transformer.inverse_transform(transformer.fit_transform(data)))

transformer = FunctionTransformer(
    lambda X: np.array([mapping[x] for x in X['value']]),
    lambda X: pd.DataFrame({'value': np.array([inv_mapping[x] for x in X])}),
validate=False)

print("It crashes:")
print(transformer.inverse_transform(transformer.fit_transform(data)))
```

#### Expected Results
I expect no error to be raised.

#### Actual Results
En error is raised when check_inverse=True

#### Versions

```
System:
    python: 3.6.12 (default, Dec 02 2020, 09:44:23) [GCC]
executable: /home/aabraham/py36/bin/python3
   machine: Linux-5.3.18-lp152.69-default-x86_64-with-glibc2.3.4

Python dependencies:
          pip: 21.0.1
   setuptools: 44.1.1
      sklearn: 0.24.1
        numpy: 1.19.5
        scipy: 1.5.4
       Cython: 0.29.21
       pandas: 1.1.5
   matplotlib: 3.3.3
       joblib: 0.17.0
threadpoolctl: 2.1.0

Built with OpenMP: True

```

'DataFrame' object has no attribute 'dtype'
### Describe the bug

I believe my attached program is correct, but it errors out prematurely.  with the error:  DataFrame' object has no attribute 'dtype'.

I've spent many hours trying to debug this.  I know the key trace line is 

```pytb
packages/sklearn/preprocessing/_function_transformer.py", line 177, in _check_inverse_transform
    if not np.issubdtype(X.dtype, np.number):
```

The problem is X is a Pandas DataFrame, which does not have a dtype attribute.  

### Steps/Code to Reproduce

```python
import numpy as np
import pandas as pd
from sklearn.compose import ColumnTransformer
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import FunctionTransformer

data = {
    "date": ["2022-09-15", "2022-09-16", "2022-09-17"],
    "close": [100.0, 101.0, 102.0]
}
df = pd.DataFrame(data)

pipe = Pipeline([
    ('colxform', ColumnTransformer(
        transformers=[
            ("close", FunctionTransformer(func=np.log, inverse_func=np.exp, feature_names_out="one-to-one"), ["close"]),
        ], remainder='passthrough')
    ),
])

pipe.fit(df)
df_xform = pipe.transform(df)
df_inverse = pipe.inverse_transform(df_xform)

assert df == df_inverse
```

### Expected Results

Expecting the round trip thru transform() and inverse_transform() to yield the identity function.  

### Actual Results

```pytb
Traceback (most recent call last):
  File "/Users/gerard/......../src/test/python/bug_function_xformer.py", line 21, in <module>
    pipe.fit(df)
  File "/Users/gerard/......../env/lib/python3.9/site-packages/sklearn/pipeline.py", line 406, in fit
    self._final_estimator.fit(Xt, y, **fit_params_last_step)
  File "/Users/gerard/......../env/lib/python3.9/site-packages/sklearn/compose/_column_transformer.py", line 693, in fit
    self.fit_transform(X, y=y)
  File "/Users/gerard/......../env/lib/python3.9/site-packages/sklearn/utils/_set_output.py", line 142, in wrapped
    data_to_wrap = f(self, X, *args, **kwargs)
  File "/Users/gerard/......../env/lib/python3.9/site-packages/sklearn/compose/_column_transformer.py", line 726, in fit_transform
    result = self._fit_transform(X, y, _fit_transform_one)
  File "/Users/gerard/......../env/lib/python3.9/site-packages/sklearn/compose/_column_transformer.py", line 657, in _fit_transform
    return Parallel(n_jobs=self.n_jobs)(
  File "/Users/gerard/......../env/lib/python3.9/site-packages/joblib/parallel.py", line 1048, in __call__
    if self.dispatch_one_batch(iterator):
  File "/Users/gerard/......../env/lib/python3.9/site-packages/joblib/parallel.py", line 864, in dispatch_one_batch
    self._dispatch(tasks)
  File "/Users/gerard/......../env/lib/python3.9/site-packages/joblib/parallel.py", line 782, in _dispatch
    job = self._backend.apply_async(batch, callback=cb)
  File "/Users/gerard/......../env/lib/python3.9/site-packages/joblib/_parallel_backends.py", line 208, in apply_async
    result = ImmediateResult(func)
  File "/Users/gerard/......../env/lib/python3.9/site-packages/joblib/_parallel_backends.py", line 572, in __init__
    self.results = batch()
  File "/Users/gerard/......../env/lib/python3.9/site-packages/joblib/parallel.py", line 263, in __call__
    return [func(*args, **kwargs)
  File "/Users/gerard/......../env/lib/python3.9/site-packages/joblib/parallel.py", line 263, in <listcomp>
    return [func(*args, **kwargs)
  File "/Users/gerard/......../env/lib/python3.9/site-packages/sklearn/utils/fixes.py", line 117, in __call__
    return self.function(*args, **kwargs)
  File "/Users/gerard/......../env/lib/python3.9/site-packages/sklearn/pipeline.py", line 894, in _fit_transform_one
    res = transformer.fit_transform(X, y, **fit_params)
  File "/Users/gerard/......../env/lib/python3.9/site-packages/sklearn/utils/_set_output.py", line 142, in wrapped
    data_to_wrap = f(self, X, *args, **kwargs)
  File "/Users/gerard/......../env/lib/python3.9/site-packages/sklearn/utils/_set_output.py", line 142, in wrapped
    data_to_wrap = f(self, X, *args, **kwargs)
  File "/Users/gerard/......../env/lib/python3.9/site-packages/sklearn/base.py", line 848, in fit_transform
    return self.fit(X, **fit_params).transform(X)
  File "/Users/gerard/......../env/lib/python3.9/site-packages/sklearn/preprocessing/_function_transformer.py", line 213, in fit
    self._check_inverse_transform(X)
  File "/Users/gerard/......../env/lib/python3.9/site-packages/sklearn/preprocessing/_function_transformer.py", line 177, in _check_inverse_transform
    if not np.issubdtype(X.dtype, np.number):
  File "/Users/gerard/......../env/lib/python3.9/site-packages/pandas/core/generic.py", line 5575, in __getattr__
    return object.__getattribute__(self, name)
AttributeError: 'DataFrame' object has no attribute 'dtype'
```

### Versions

```shell
>>> import sklearn; sklearn.show_versions()

System:
    python: 3.9.15 (main, Nov 24 2022, 08:29:02)  [Clang 14.0.6 ]
executable: ......../env/bin/python3
   machine: macOS-10.16-x86_64-i386-64bit

Python dependencies:
      sklearn: 1.2.0
          pip: 22.3.1
   setuptools: 65.5.0
        numpy: 1.23.5
        scipy: 1.8.1
       Cython: 0.29.32
       pandas: 1.4.4
   matplotlib: 3.6.2
       joblib: 1.1.1
threadpoolctl: 2.2.0

Built with OpenMP: True

threadpoolctl info:
       filepath: ......../env/lib/libopenblasp-r0.3.21.dylib
         prefix: libopenblas
       user_api: blas
   internal_api: openblas
        version: 0.3.21
    num_threads: 6
threading_layer: pthreads
   architecture: Haswell

       filepath: ......../env/lib/libomp.dylib
         prefix: libomp
       user_api: openmp
   internal_api: openmp
        version: None
    num_threads: 12
>>>
```

Hints from issue discussion:
Yep, the round-trip check was only designed for numerical. We should add support for object data type: it should be more expensive but we could check the data type of `X` and then choose the type of check to apply.
take
Also related with https://github.com/scikit-learn/scikit-learn/issues/19905
We introduced the problem in https://github.com/scikit-learn/scikit-learn/pull/19916

Commit message: DOC Adds whats new number

Patch to review:
diff --git a/doc/whats_new/v1.2.rst b/doc/whats_new/v1.2.rst
index c99ad4c06ea2f..a8087472a6e10 100644
--- a/doc/whats_new/v1.2.rst
+++ b/doc/whats_new/v1.2.rst
@@ -27,6 +27,13 @@ Changelog
   `verbose` parameter set to a value greater than 0.
   :pr:`25250` by :user:`Jérémie Du Boisberranger <jeremiedbb>`.
 
+:mod:`sklearn.preprocessing`
+............................
+
+- |Fix| :meth:`preprocessing.FunctionTransformer.inverse_transform` correctly
+  supports DataFrames that all all numerical when `check_inverse=True`.
+  :pr:`25274` by `Thomas Fan`_.
+
 :mod:`sklearn.utils`
 ....................
 
diff --git a/sklearn/preprocessing/_function_transformer.py b/sklearn/preprocessing/_function_transformer.py
index d4c2cf6de7af2..f3d98903f8571 100644
--- a/sklearn/preprocessing/_function_transformer.py
+++ b/sklearn/preprocessing/_function_transformer.py
@@ -174,7 +174,13 @@ def _check_inverse_transform(self, X):
         idx_selected = slice(None, None, max(1, X.shape[0] // 100))
         X_round_trip = self.inverse_transform(self.transform(X[idx_selected]))
 
-        if not np.issubdtype(X.dtype, np.number):
+        if hasattr(X, "dtype"):
+            dtypes = [X.dtype]
+        elif hasattr(X, "dtypes"):
+            # Dataframes can have multiple dtypes
+            dtypes = X.dtypes
+
+        if not all(np.issubdtype(d, np.number) for d in dtypes):
             raise ValueError(
                 "'check_inverse' is only supported when all the elements in `X` is"
                 " numerical."
diff --git a/sklearn/preprocessing/tests/test_function_transformer.py b/sklearn/preprocessing/tests/test_function_transformer.py
index b10682922acd0..557aac53def83 100644
--- a/sklearn/preprocessing/tests/test_function_transformer.py
+++ b/sklearn/preprocessing/tests/test_function_transformer.py
@@ -217,6 +217,36 @@ def inverse_func(X):
         transformer.fit(data)
 
 
+def test_function_transformer_support_all_nummerical_dataframes_check_inverse_True():
+    """Check support for dataframes with only numerical values."""
+    pd = pytest.importorskip("pandas")
+
+    df = pd.DataFrame({"a": [1, 2, 3], "b": [4, 5, 6]})
+    transformer = FunctionTransformer(
+        func=lambda x: x + 2, inverse_func=lambda x: x - 2, check_inverse=True
+    )
+
+    # Does not raise an error
+    df_out = transformer.fit_transform(df)
+    assert_allclose_dense_sparse(df_out, df + 2)
+
+
+def test_function_transformer_with_dataframe_and_check_inverse_True():
+    """Raise error is raised when check_inverse=True.
+
+    Non-regresion test for gh-25261.
+    """
+    pd = pytest.importorskip("pandas")
+    transformer = FunctionTransformer(
+        func=lambda x: x, inverse_func=lambda x: x, check_inverse=True
+    )
+
+    df_mixed = pd.DataFrame({"a": [1, 2, 3], "b": ["a", "b", "c"]})
+    msg = "'check_inverse' is only supported when all the elements in `X` is numerical."
+    with pytest.raises(ValueError, match=msg):
+        transformer.fit(df_mixed)
+
+
 @pytest.mark.parametrize(
     "X, feature_names_out, input_features, expected",
     [


Code review comments:
diff_hunk: @@ -217,6 +217,36 @@ def inverse_func(X):
         transformer.fit(data)
 
 
+def test_function_transformer_support_all_nummerical_dataframes_check_inverse_True():
+    """Check support for dataframes with only numerical values."""
+    pd = pytest.importorskip("pandas")
+
+    df = pd.DataFrame({"a": [1, 2, 3], "b": [4, 5, 6]})
+    transformer = FunctionTransformer(
+        func=lambda x: x + 2, inverse_func=lambda x: x - 2, check_inverse=True
+    )
+
+    # Does not raise an error
+    df_out = transformer.fit_transform(df)
+    assert_allclose_dense_sparse(df_out, df + 2)
+
+
+def test_function_transformer_with_dataframe_and_check_inverse_True():
+    """Raise error is raised when check_inverse=True.
path: sklearn/preprocessing/tests/test_function_transformer.py
text: @user1:
```suggestion
    """Check error is raised when check_inverse=True.
```

diff_hunk: @@ -27,6 +27,13 @@ Changelog
   `verbose` parameter set to a value greater than 0.
   :pr:`25250` by :user:`Jérémie Du Boisberranger <jeremiedbb>`.
 
+:mod:`sklearn.preprocessing`
+............................
+
+- |Fix| :meth:`preprocessing.FunctionTransformer.inverse_transform` correctly
+  supports DataFrames that all all numerical when `check_inverse=True`.
path: doc/whats_new/v1.2.rst
text: @user1:
```suggestion
  supports DataFrames that are all numerical when `check_inverse=True`.
```