Repository: scikit-learn/scikit-learn
Pull request: ENH FeatureUnion: Add verbose_feature_names_out parameter
<!--
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
Fixes #25889. 

#### What does this implement/fix? Explain your changes.
To have a ```verbose_feature_names_out``` attribute in FeatureUnion. 
If True, ```get_feature_names_out``` will prefix all feature names with the name of the transformer that generated that feature. If False, ```get_feature_names_out``` will not prefix any feature names and will error if feature names are not unique.
* Add ```verbose_feature_names_out``` attribute to class ```FeatureUnion```
* Modify ```get_feature_names_out``` to not add prefix automatically
* Add function ```_add_prefix_for_feature_names_out``` to add prefix when ```verbose_feature_names_out``` is True and check that names are all unique without a prefix if False
* Add test function ```test_feature_union_passthrough_get_feature_names_out_false ```  to check for feature_names_out for verbose_feature_names_out=False
* Add test_feature_union_passthrough_get_feature_names_out_false_errors to test when collisions happen, there are names the same without a prefix. To make the test cover ```_add_prefix_for_feature_names_out``` function



#### Any other comments?


<!--
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:
FeatureUnion: Add verbose_feature_names_out parameter
### Describe the workflow you want to enable

`ColumnTransformer` has the option to specify whether or not to "prefix all feature names with the name of the transformer that generated that feature" using the `verbose_feature_names_out` parameter.

`FeatureUnion` does not have this option. As a user I would like to control whether or not I want to have the prefix added. Right now, the prefix is added without any option to turn that functionality off.

### Describe your proposed solution

I propose to add the same parameter that exists in `ColumnTransformer` to `FeatureUnion` so that the user can decide whether or not they would like to have the prefix or not.

### Describe alternatives you've considered, if relevant

**The user can remove the prefox manually after FeatureUnion has transformed the data**
Requires to identify the columns in question and remove the prefix. Removing the prefix via a simple `str.split("__")[1]` could cause problems if the feature names already include `__`. If that is the case a more complicated way to remove the prefix is required by the user.

**The user can write their own implementation of FeatureUnion that changes the method in question**
```
from sklearn.pipeline import FeatureUnion

class NewFeatureUnion(FeatureUnion):
    def get_feature_names_out(self, input_features=None):
        """
        Overwrites the original method to not modify the features names
        """
        feature_names = []
        for name, trans, _ in self._iter():
            if not hasattr(trans, "get_feature_names_out"):
                raise AttributeError(
                    "Transformer %s (type %s) does not provide get_feature_names_out."
                    % (str(name), type(trans).__name__)
                )
            feature_names.extend(
                [f for f in trans.get_feature_names_out(input_features)]
            )
        return np.asarray(feature_names, dtype=object)
```
Adds more code to the user's codebase that needs to be maintained. Could also cause confusion because there is no obvious class name that explains that the new implementation does, so another developer would have to find the code snippet to figure out what the new implementation does. 

### Additional context

There might be a good reason why we don't want to have the same functionality in `FeatureUnion` that is available for `ColumnTransformer`. This is my first issue so I might be missing something.

A one-to-one implementation of the parameter might break things that are dependent on `FeatureUnion`. I am not that familiar with the rest of the codebase.

I think I would be able to create a merge request for it if this parameter request is approved.

Hints from issue discussion:
I think it makes sense to have a `verbose_feature_names_out` in `FeatureUnion` that is similar to `ColumnTransformer`. The implementation should end up similar to ColumnTransformer:

https://github.com/scikit-learn/scikit-learn/blob/e3d1f9ac39e4bf0f31430e779acc50fb05fe1b64/sklearn/compose/_column_transformer.py#L492

It has a `_add_prefix_for_feature_names_out` to check if feature names are the same when `verbose_feature_names_out=False` and give a nice error message.
Is anyone else currently working on this issue? If not, would it be okay for me to take it on?
@jiawei-zhang-a Feel free to take it. I was gonna work on it probably next week but you can take it instead if you want.
@sebmoh Thanks! I will implement it within this week
/take
@thomasjpfan Hi Mr. Fan, I have made a PR for this issue, would mind taking a look at it.

Commit message: Merge branch 'main' into my_feature

Patch to review:
diff --git a/doc/whats_new/v1.3.rst b/doc/whats_new/v1.3.rst
index 07b581f2104b1..fa120f0d01dec 100644
--- a/doc/whats_new/v1.3.rst
+++ b/doc/whats_new/v1.3.rst
@@ -376,6 +376,13 @@ Changelog
   `pandas.DataFrame`
   :pr:`25220` by :user:`Ian Thompson <it176131>`.
 
+- |Feature| :class:`pipeline.FeatureUnion` can now use the
+  `verbose_feature_names_out` attribute. If `True`, `get_feature_names_out`
+  will prefix all feature names with the name of the transformer
+  that generated that feature. If `False`, `get_feature_names_out` will not
+  prefix any feature names and will error if feature names are not unique.
+  :pr:`25991` by :user:`Jiawei Zhang <jiawei-zhang-a>`.
+
 :mod:`sklearn.preprocessing`
 ............................
 
diff --git a/sklearn/pipeline.py b/sklearn/pipeline.py
index a604bb6fc6e6e..b2e4f68ed24cc 100644
--- a/sklearn/pipeline.py
+++ b/sklearn/pipeline.py
@@ -9,8 +9,8 @@
 #         Lars Buitinck
 # License: BSD
 
-from collections import defaultdict
-from itertools import islice
+from collections import defaultdict, Counter
+from itertools import islice, chain
 
 import numpy as np
 from scipy import sparse
@@ -977,6 +977,12 @@ class FeatureUnion(TransformerMixin, _BaseComposition):
         If True, the time elapsed while fitting each transformer will be
         printed as it is completed.
 
+    verbose_feature_names_out : bool, default=True
+        If True, :meth:`get_feature_names_out` will prefix all feature names
+        with the name of the transformer that generated that feature.
+        If False, :meth:`get_feature_names_out` will not prefix any feature
+        names and will error if feature names are not unique.
+
     Attributes
     ----------
     named_transformers : :class:`~sklearn.utils.Bunch`
@@ -1020,12 +1026,19 @@ class FeatureUnion(TransformerMixin, _BaseComposition):
     _required_parameters = ["transformer_list"]
 
     def __init__(
-        self, transformer_list, *, n_jobs=None, transformer_weights=None, verbose=False
+        self,
+        transformer_list,
+        *,
+        n_jobs=None,
+        transformer_weights=None,
+        verbose=False,
+        verbose_feature_names_out=True,
     ):
         self.transformer_list = transformer_list
         self.n_jobs = n_jobs
         self.transformer_weights = transformer_weights
         self.verbose = verbose
+        self.verbose_feature_names_out = verbose_feature_names_out
 
     def set_output(self, *, transform=None):
         """Set the output container when `"transform"` and `"fit_transform"` are called.
@@ -1156,17 +1169,66 @@ def get_feature_names_out(self, input_features=None):
         feature_names_out : ndarray of str objects
             Transformed feature names.
         """
-        feature_names = []
+        # List of tuples (name, feature_names_out)
+        transformer_with_feature_names_out = []
         for name, trans, _ in self._iter():
             if not hasattr(trans, "get_feature_names_out"):
                 raise AttributeError(
                     "Transformer %s (type %s) does not provide get_feature_names_out."
                     % (str(name), type(trans).__name__)
                 )
-            feature_names.extend(
-                [f"{name}__{f}" for f in trans.get_feature_names_out(input_features)]
+            feature_names_out = trans.get_feature_names_out(input_features)
+            transformer_with_feature_names_out.append((name, feature_names_out))
+
+        return self._add_prefix_for_feature_names_out(
+            transformer_with_feature_names_out
+        )
+
+    def _add_prefix_for_feature_names_out(self, transformer_with_feature_names_out):
+        """Add prefix for feature names out that includes the transformer names.
+        Parameters
+        ----------
+        transformer_with_feature_names_out : list of tuples of (str, array-like of str)
+            The tuple consistent of the transformer's name and its feature names out.
+        Returns
+        -------
+        feature_names_out : ndarray of shape (n_features,), dtype=str
+            Transformed feature names.
+        """
+        if self.verbose_feature_names_out:
+            # Prefix the feature names out with the transformers name
+            names = list(
+                chain.from_iterable(
+                    (f"{name}__{i}" for i in feature_names_out)
+                    for name, feature_names_out in transformer_with_feature_names_out
+                )
             )
-        return np.asarray(feature_names, dtype=object)
+            return np.asarray(names, dtype=object)
+
+        # verbose_feature_names_out is False
+        # Check that names are all unique without a prefix
+        feature_names_count = Counter(
+            chain.from_iterable(s for _, s in transformer_with_feature_names_out)
+        )
+        top_6_overlap = [
+            name for name, count in feature_names_count.most_common(6) if count > 1
+        ]
+        top_6_overlap.sort()
+        if top_6_overlap:
+            if len(top_6_overlap) == 6:
+                # There are more than 5 overlapping names, we only show the 5
+                # of the feature names
+                names_repr = str(top_6_overlap[:5])[:-1] + ", ...]"
+            else:
+                names_repr = str(top_6_overlap)
+            raise ValueError(
+                f"Output feature names: {names_repr} are not unique. Please set "
+                "verbose_feature_names_out=True to add prefixes to feature names"
+            )
+
+        return np.concatenate(
+            [name for _, name in transformer_with_feature_names_out],
+        )
 
     def fit(self, X, y=None, **fit_params):
         """Fit all transformers using X.
diff --git a/sklearn/tests/test_pipeline.py b/sklearn/tests/test_pipeline.py
index fa7fa2ad20dcf..9567b97f5a959 100644
--- a/sklearn/tests/test_pipeline.py
+++ b/sklearn/tests/test_pipeline.py
@@ -40,6 +40,7 @@
 from sklearn.ensemble import HistGradientBoostingClassifier
 from sklearn.impute import SimpleImputer
 
+
 iris = load_iris()
 
 JUNK_FOOD_DOCS = (
@@ -1119,10 +1120,8 @@ def test_set_feature_union_passthrough():
     )
 
 
-def test_feature_union_passthrough_get_feature_names_out():
-    """Check that get_feature_names_out works with passthrough without
-    passing input_features.
-    """
+def test_feature_union_passthrough_get_feature_names_out_true():
+    """Check feature_names_out for verbose_feature_names_out=True (default)"""
     X = iris.data
     pca = PCA(n_components=2, svd_solver="randomized", random_state=0)
 
@@ -1141,6 +1140,124 @@ def test_feature_union_passthrough_get_feature_names_out():
     )
 
 
+def test_feature_union_passthrough_get_feature_names_out_false():
+    """Check feature_names_out for verbose_feature_names_out=False"""
+    X = iris.data
+    pca = PCA(n_components=2, svd_solver="randomized", random_state=0)
+
+    ft = FeatureUnion(
+        [("pca", pca), ("passthrough", "passthrough")], verbose_feature_names_out=False
+    )
+    ft.fit(X)
+    print(ft.get_feature_names_out())
+    assert_array_equal(
+        [
+            "pca0",
+            "pca1",
+            "x0",
+            "x1",
+            "x2",
+            "x3",
+        ],
+        ft.get_feature_names_out(),
+    )
+
+
+def test_feature_union_passthrough_get_feature_names_out_false_errors():
+    """
+    Check feature_names_out for verbose_feature_names_out=False,
+    Collisions in feature names
+    """
+
+    # Class to test feature names collisions
+    class CollidingFeatureNamesTransformer:
+        def fit(self, X, y=None):
+            return self
+
+        def transform(self, X):
+            return X[:, :2]
+
+        def get_feature_names_out(self, input_features=None):
+            return np.array(["colliding_name", "colliding_name"])
+
+    # Test with verbose_feature_names_out=False when collisions occur
+    transformer1 = CollidingFeatureNamesTransformer()
+    transformer2 = CollidingFeatureNamesTransformer()
+
+    fu = FeatureUnion(
+        [("t1", transformer1), ("t2", transformer2)],
+        verbose_feature_names_out=False,
+    )
+
+    X = np.random.rand(10, 4)
+
+    fu.fit(X)
+
+    msg = re.escape(
+        "Output feature names: ['colliding_name'] are not unique. "
+        "Please set verbose_feature_names_out=True to add prefixes to feature names"
+    )
+
+    # Replace the escaped ",\\ " with the optional non-capturing group
+    # to match error message
+    msg = msg.replace(",\\\\ ", "(?:, )?")
+
+    with pytest.raises(ValueError, match=msg):
+        fu.get_feature_names_out()
+
+
+def test_feature_union_passthrough_get_feature_names_out_false_errors_overlap_over_5():
+    """
+    Check feature_names_out for verbose_feature_names_out=False,
+    Collisions in feature names
+    There are more than 5 overlapping names
+    """
+
+    class CollidingFeatureNamesTransformer:
+        def __init__(self, names):
+            self.names = names
+
+        def fit(self, X, y=None):
+            return self
+
+        def transform(self, X):
+            return X[:, : len(self.names)]
+
+        def get_feature_names_out(self, input_features=None):
+            return np.array(self.names)
+
+    # Test with verbose_feature_names_out=False when collisions occur
+    transformer1 = CollidingFeatureNamesTransformer(
+        ["overlap1", "overlap2", "overlap3", "overlap4", "overlap5", "overlap6"]
+    )
+    transformer2 = CollidingFeatureNamesTransformer(
+        ["overlap1", "overlap2", "overlap3", "overlap4", "overlap5", "overlap6"]
+    )
+
+    fu = FeatureUnion(
+        [("t1", transformer1), ("t2", transformer2)],
+        verbose_feature_names_out=False,
+    )
+
+    X = np.random.rand(10, 6)
+
+    fu.fit(X)
+
+    msg = (
+        r"Output feature names: \['overlap1', 'overlap2', 'overlap3'(?:, 'overlap4',"
+        r" 'overlap5')?, \.\.\.\] "
+        r"are not unique\. Please set verbose_feature_names_out=True to add prefixes to"
+        r" feature names"
+    )
+
+    # Replace the escaped ",\\ " with the optional non-capturing group
+    # to match error message
+    msg = msg.replace(",\\\\ ", "(?:, )?")
+
+    with pytest.raises(ValueError, match=msg):
+        fu.get_feature_names_out()
+
+
 def test_step_name_validation():
     error_message_1 = r"Estimator names must not contain __: got \['a__q'\]"
     error_message_2 = r"Names provided are not unique: \['a', 'a'\]"


Code review comments:
diff_hunk: @@ -1141,6 +1140,124 @@ def test_feature_union_passthrough_get_feature_names_out():
     )
 
 
+def test_feature_union_passthrough_get_feature_names_out_false():
+    """Check feature_names_out for verbose_feature_names_out=False"""
+    X = iris.data
+    pca = PCA(n_components=2, svd_solver="randomized", random_state=0)
+
+    ft = FeatureUnion(
+        [("pca", pca), ("passthrough", "passthrough")], verbose_feature_names_out=False
+    )
+    ft.fit(X)
+    print(ft.get_feature_names_out())
path: sklearn/tests/test_pipeline.py
text: @user1:
No need to print with feature_names_out in tests:

```suggestion
```