Repository: scikit-learn/scikit-learn (Python)

Issue:
add get_feature_names to CategoricalEncoder
We should add a ``get_feature_names`` to the new CategoricalEncoder, as discussed [here](https://github.com/scikit-learn/scikit-learn/pull/9151#issuecomment-345830056). I think it would be good to be consistent with the PolynomialFeature which allows passing in original feature names to map them to new feature names. Also see #6425.


Discussion:
I'd like to try this one.
If you haven't contributed before, I suggest you try an issue labeled "good first issue". Though this one isn't too hard, eigher.
@amueller 
I think I can handle it.
So we want something like this right?

    enc.fit([['male',0], ['female', 1]])
    enc.get_feature_names()

    >> ['female', 'male', 0, 1]

Can you please give an example of how original feature names can map to new feature names? I have seen the `get_feature_names()` from PolynomialFeatures, but I don't understand what that means in this case.
I think the idea is that if you have multiple input features containing the
value "hello" they need to be distinguished in the feature names listed for
output. so you prefix the value with the input feature name, defaulting to
x1 etc as in polynomial. clearer?

@jnothman Is this what you mean?

    enc.fit(  [ [ 'male' ,    0,  1],
                 [ 'female' ,  1 , 0]  ] )

    enc.get_feature_names(['one','two','three'])

    >> ['one_female', 'one_male' , 'two_0' , 'two_1' , 'three_0' , 'three_1']


And in case I don't pass any strings, it should just use `x0` , `x1` and so on for the prefixes right?
Precisely.

>
>

I like the idea to be able to specify input feature names.

Regarding syntax of combining the two names, as prior art we have eg `DictVectorizer` that does something like `['0=female', '0=male', '1=0', '1=1']` (assuming we use 0 and 1 as the column names for arrays) or Pipelines that uses double underscores (`['0__female', '0__male', '1__0', '1__1']`). Others? 
I personally like the `__` a bit more I think, but the fact that this is used by pipelines is for me actually a reason to use `=` in this case. Eg in combination with the ColumnTransformer (assuming this would use the `__` syntax like pipeline), you could then get a feature name like `'cat__0=male'` instead of `'cat__0__male'`.
Additional question:

- if the input is a pandas DataFrame, do we want to preserve the column names (to use instead of 0, 1, ..)? 
  (ideally yes IMO, but this would require some extra code as currently it is not detected whether a DataFrame is passed or not, it is just coerced to array)
no, we shouldn't use column names automatically. it's hard for us to keep
them and easy for the user to pass them.

>  it's hard for us to keep them

It's not really 'hard':

```
class CategoricalEncoder():

    def fit(self, X, ...):
        ...
        if hasattr(X, 'iloc'):
            self._input_features = X.columns
        ...

    def get_feature_names(self, input_features=None):
        if input_features is None:
            input_features = self._input_features
        ...
```

but of course it is added complexity, and more explicit support for pandas dataframes, which is not necessarily something we want to add (I just don't think 'hard' is the correct reason :-)).

But eg if you combine multiple sets of columns and transformers in a ColumnTransformer, it is not always that straightforward for the user to keep track of IMO, because you then need to combine the different sets of selected column into one list to pass to `get_feature_names`.
No, then you just need get_feature_names implemented everywhere and let
Pipeline's (not yet) implementation of get_feature_names handle it for you.
(Note: There remain some problems with this design in a meta-estimator
context.) I've implemented similar within the eli5 package, but we also got
somewhat stuck when it came to making arbitrary decisions about how to make
feature names for linear transforms like PCA. A structured representation
rather than a string name might be nice...

On 23 November 2017 at 10:00, Joris Van den Bossche <
notifications@github.com> wrote:

> it's hard for us to keep them
>
> It's not really 'hard':
>
> class CategoricalEncoder():
>
>     def fit(self, X, ...):
>         ...
>         if hasattr(X, 'iloc'):
>             self._input_features = X.columns
>         ...
>
>     def get_feature_names(self, input_features=None):
>         if input_features is None:
>             input_features = self._input_features
>         ...
>
> but of course it is added complexity, and more explicit support for pandas
> dataframes, which is not necessarily something we want to add (I just don't
> think 'hard' is the correct reason :-)).
>
> But eg if you combine multiple sets of columns and transformers in a
> ColumnTransformer, it is not always that straightforward for the user to
> keep track of IMO, because you then need to combine the different sets of
> selected column into one list to pass to get_feature_names.
>
> —
> You are receiving this because you were mentioned.
> Reply to this email directly, view it on GitHub
> <https://github.com/scikit-learn/scikit-learn/issues/10181#issuecomment-346495657>,
> or mute the thread
> <https://github.com/notifications/unsubscribe-auth/AAEz62rb6pYYTi80NzltL4u4biA3_-ARks5s5KePgaJpZM4Ql59C>
> .
>




Patch:
diff --git a/doc/whats_new/v0.20.rst b/doc/whats_new/v0.20.rst
index 5b9216926b834..f9b76dc355bca 100644
--- a/doc/whats_new/v0.20.rst
+++ b/doc/whats_new/v0.20.rst
@@ -302,6 +302,11 @@ Preprocessing
   :class:`feature_extraction.text.CountVectorizer` initialized with a
   vocabulary. :issue:`10908` by :user:`Mohamed Maskani <maskani-moh>`.
 
+- :class:`preprocessing.OneHotEncoder` now supports the
+  :meth:`get_feature_names` method to obtain the transformed feature names.
+  :issue:`10181` by  :user:`Nirvan Anjirbag <Nirvan101>` and
+  `Joris Van den Bossche`_.
+
 - The ``transform`` method of :class:`sklearn.preprocessing.MultiLabelBinarizer`
   now ignores any unknown classes. A warning is raised stating the unknown classes
   classes found which are ignored.
diff --git a/sklearn/preprocessing/_encoders.py b/sklearn/preprocessing/_encoders.py
index 4bd11012b008b..bd6e10fb62810 100644
--- a/sklearn/preprocessing/_encoders.py
+++ b/sklearn/preprocessing/_encoders.py
@@ -240,6 +240,8 @@ class OneHotEncoder(_BaseEncoder):
     >>> enc.inverse_transform([[0, 1, 1, 0, 0], [0, 0, 0, 1, 0]])
     array([['Male', 1],
            [None, 2]], dtype=object)
+    >>> enc.get_feature_names()
+    array(['x0_Female', 'x0_Male', 'x1_1', 'x1_2', 'x1_3'], dtype=object)
 
     See also
     --------
@@ -639,6 +641,38 @@ def inverse_transform(self, X):
 
         return X_tr
 
+    def get_feature_names(self, input_features=None):
+        """Return feature names for output features.
+
+        Parameters
+        ----------
+        input_features : list of string, length n_features, optional
+            String names for input features if available. By default,
+            "x0", "x1", ... "xn_features" is used.
+
+        Returns
+        -------
+        output_feature_names : array of string, length n_output_features
+
+        """
+        check_is_fitted(self, 'categories_')
+        cats = self.categories_
+        if input_features is None:
+            input_features = ['x%d' % i for i in range(len(cats))]
+        elif(len(input_features) != len(self.categories_)):
+            raise ValueError(
+                "input_features should have length equal to number of "
+                "features ({}), got {}".format(len(self.categories_),
+                                               len(input_features)))
+
+        feature_names = []
+        for i in range(len(cats)):
+            names = [
+                input_features[i] + '_' + six.text_type(t) for t in cats[i]]
+            feature_names.extend(names)
+
+        return np.array(feature_names, dtype=object)
+
 
 class OrdinalEncoder(_BaseEncoder):
     """Encode categorical features as an integer array.


Test patch:
diff --git a/sklearn/preprocessing/tests/test_encoders.py b/sklearn/preprocessing/tests/test_encoders.py
index e655acd20b30d..9ec16b85df60d 100644
--- a/sklearn/preprocessing/tests/test_encoders.py
+++ b/sklearn/preprocessing/tests/test_encoders.py
@@ -1,3 +1,4 @@
+# -*- coding: utf-8 -*-
 from __future__ import division
 
 import re
@@ -455,6 +456,47 @@ def test_one_hot_encoder_pandas():
     assert_allclose(Xtr, [[1, 0, 1, 0], [0, 1, 0, 1]])
 
 
+def test_one_hot_encoder_feature_names():
+    enc = OneHotEncoder()
+    X = [['Male', 1, 'girl', 2, 3],
+         ['Female', 41, 'girl', 1, 10],
+         ['Male', 51, 'boy', 12, 3],
+         ['Male', 91, 'girl', 21, 30]]
+
+    enc.fit(X)
+    feature_names = enc.get_feature_names()
+    assert isinstance(feature_names, np.ndarray)
+
+    assert_array_equal(['x0_Female', 'x0_Male',
+                        'x1_1', 'x1_41', 'x1_51', 'x1_91',
+                        'x2_boy', 'x2_girl',
+                        'x3_1', 'x3_2', 'x3_12', 'x3_21',
+                        'x4_3',
+                        'x4_10', 'x4_30'], feature_names)
+
+    feature_names2 = enc.get_feature_names(['one', 'two',
+                                            'three', 'four', 'five'])
+
+    assert_array_equal(['one_Female', 'one_Male',
+                        'two_1', 'two_41', 'two_51', 'two_91',
+                        'three_boy', 'three_girl',
+                        'four_1', 'four_2', 'four_12', 'four_21',
+                        'five_3', 'five_10', 'five_30'], feature_names2)
+
+    with pytest.raises(ValueError, match="input_features should have length"):
+        enc.get_feature_names(['one', 'two'])
+
+
+def test_one_hot_encoder_feature_names_unicode():
+    enc = OneHotEncoder()
+    X = np.array([[u'c❤t1', u'dat2']], dtype=object).T
+    enc.fit(X)
+    feature_names = enc.get_feature_names()
+    assert_array_equal([u'x0_c❤t1', u'x0_dat2'], feature_names)
+    feature_names = enc.get_feature_names(input_features=[u'n👍me'])
+    assert_array_equal([u'n👍me_c❤t1', u'n👍me_dat2'], feature_names)
+
+
 @pytest.mark.parametrize("X", [
     [['abc', 2, 55], ['def', 1, 55]],
     np.array([[10, 2, 55], [20, 1, 55]]),