Repository: alteryx/evalml (python)

Issue:
OneHotEncoder: allow user to filter which features to encode
**Background**
Currently, `OneHotEncoder` will encode all features passed in during `fit` which are categorical. That's great; simple and easy to understand.

**Proposal**
Suppose a user wants to apply the encoder to a subset of the features. Today, they could do this by filtering which features they pass to the call to `fit`. But it could be easier to support a `features_to_encode` list input, which specifies the feature names to include in the encoding, provided they're categorical features.

Error cases to handle: user specifies a feature which doesn't exist, or feature isn't categorical type.

Open question: what happens to other categoricals? Do they get passed through un-encoded? That seems like the right thing to do, but we should consider.

**Implementation Suggestion**
The first thing `OneHotEncoder.fit` currently does is get all the categorical features by calling `self._get_cat_cols(X_t)`. We should now instead take the intersection of that output with `features_to_encode`.

Pull request description:
Allow user to filter which features to encode for OneHotEncoder
Closes #1237.

I chose to raise a ValueError if any column in `features_to_encode` does not exist in the input DataFrame.

Patch:
diff --git a/docs/source/release_notes.rst b/docs/source/release_notes.rst
index 430d8a8a..97f7d3c2 100644
--- a/docs/source/release_notes.rst
+++ b/docs/source/release_notes.rst
@@ -4,7 +4,7 @@ Release Notes
 **Future Releases**
     * Enhancements
         * Added stacked ensemble component classes (``StackedEnsembleClassifier``, ``StackedEnsembleRegressor``) :pr:`1134`
-        * Added stacked ensemble component classes (StackedEnsembleClassifier, StackedEnsembleRegressor) :pr:`1134`
+        * Added parameter to ``OneHotEncoder`` to enable filtering for features to encode for :pr:`1249`
         * Added percent-better-than-baseline for all objectives to automl.results :pr:`1244`
     * Fixes
     * Changes
diff --git a/evalml/pipelines/components/transformers/encoders/onehot_encoder.py b/evalml/pipelines/components/transformers/encoders/onehot_encoder.py
index 1dbd2199..8c663cdf 100644
--- a/evalml/pipelines/components/transformers/encoders/onehot_encoder.py
+++ b/evalml/pipelines/components/transformers/encoders/onehot_encoder.py
@@ -20,6 +20,7 @@ class OneHotEncoder(Transformer, metaclass=OneHotEncoderMeta):
 
     def __init__(self,
                  top_n=10,
+                 features_to_encode=None,
                  categories=None,
                  drop=None,
                  handle_unknown="ignore",
@@ -31,6 +32,8 @@ class OneHotEncoder(Transformer, metaclass=OneHotEncoderMeta):
         Arguments:
             top_n (int): Number of categories per column to encode. If None, all categories will be encoded.
                 Otherwise, the `n` most frequent will be encoded and all others will be dropped. Defaults to 10.
+            features_to_encode (list(str)): List of columns to encode. All other columns will remain untouched.
+                If None, all appropriate columns will be encoded. Defaults to None.
             categories (list): A two dimensional list of categories, where `categories[i]` is a list of the categories
                 for the column at index `i`. This can also be `None`, or `"auto"` if `top_n` is not None. Defaults to None.
             drop (string): Method ("first" or "if_binary") to use to drop one category per feature. Can also be
@@ -44,6 +47,7 @@ class OneHotEncoder(Transformer, metaclass=OneHotEncoderMeta):
                 values encountered will raise an error. Defaults to "error".
         """
         parameters = {"top_n": top_n,
+                      "features_to_encode": features_to_encode,
                       "categories": categories,
                       "drop": drop,
                       "handle_unknown": handle_unknown,
@@ -60,7 +64,7 @@ class OneHotEncoder(Transformer, metaclass=OneHotEncoderMeta):
         if top_n is not None and categories is not None:
             raise ValueError("Cannot use categories and top_n arguments simultaneously")
 
-        self._cols_to_encode = None
+        self.features_to_encode = features_to_encode
         self._encoder = None
         super().__init__(parameters=parameters,
                          component_obj=None,
@@ -80,24 +84,29 @@ class OneHotEncoder(Transformer, metaclass=OneHotEncoderMeta):
         if not isinstance(X, pd.DataFrame):
             X = pd.DataFrame(X)
         X_t = X
-        self._cols_to_encode = self._get_cat_cols(X_t)
+
+        if self.features_to_encode is None:
+            self.features_to_encode = self._get_cat_cols(X_t)
+        invalid_features = [col for col in self.features_to_encode if col not in list(X.columns)]
+        if len(invalid_features) > 0:
+            raise ValueError("Could not find and encode {} in input data.".format(', '.join(invalid_features)))
 
         if self.parameters['handle_missing'] == "as_category":
-            X_t[self._cols_to_encode] = X_t[self._cols_to_encode].replace(np.nan, "nan")
+            X_t[self.features_to_encode] = X_t[self.features_to_encode].replace(np.nan, "nan")
         elif self.parameters['handle_missing'] == "error" and X.isnull().any().any():
             raise ValueError("Input contains NaN")
 
-        if len(self._cols_to_encode) == 0:
+        if len(self.features_to_encode) == 0:
             categories = 'auto'
 
         elif self.parameters['categories'] is not None:
             categories = self.parameters['categories']
-            if len(categories) != len(self._cols_to_encode) or not isinstance(categories[0], list):
+            if len(categories) != len(self.features_to_encode) or not isinstance(categories[0], list):
                 raise ValueError('Categories argument must contain a list of categories for each categorical feature')
 
         else:
             categories = []
-            for col in X_t[self._cols_to_encode]:
+            for col in X_t[self.features_to_encode]:
                 value_counts = X_t[col].value_counts(dropna=False).to_frame()
                 if top_n is None or len(value_counts) <= top_n:
                     unique_values = value_counts.index.tolist()
@@ -112,7 +121,7 @@ class OneHotEncoder(Transformer, metaclass=OneHotEncoderMeta):
         self._encoder = SKOneHotEncoder(categories=categories,
                                         drop=self.parameters['drop'],
                                         handle_unknown=self.parameters['handle_unknown'])
-        self._encoder.fit(X_t[self._cols_to_encode])
+        self._encoder.fit(X_t[self.features_to_encode])
         return self
 
     def transform(self, X, y=None):
@@ -128,7 +137,8 @@ class OneHotEncoder(Transformer, metaclass=OneHotEncoderMeta):
 
         if not isinstance(X, pd.DataFrame):
             X = pd.DataFrame(X)
-        cat_cols = self._get_cat_cols(X)
+
+        cat_cols = self.features_to_encode
 
         if self.parameters['handle_missing'] == "as_category":
             X[cat_cols] = X[cat_cols].replace(np.nan, "nan")
@@ -162,7 +172,7 @@ class OneHotEncoder(Transformer, metaclass=OneHotEncoderMeta):
             np.array: the unique categories, in the same dtype as they were provided during fit
         """
         try:
-            index = self._cols_to_encode.index(feature_name)
+            index = self.features_to_encode.index(feature_name)
         except Exception:
             raise ValueError(f'Feature "{feature_name}" was not provided to one-hot encoder as a training feature')
         return self._encoder.categories_[index]
@@ -173,4 +183,4 @@ class OneHotEncoder(Transformer, metaclass=OneHotEncoderMeta):
         Returns:
             np.array: The feature names after encoding, provided in the same order as input_features.
         """
-        return self._encoder.get_feature_names(self._cols_to_encode)
+        return self._encoder.get_feature_names(self.features_to_encode)


Test patch:
diff --git a/evalml/tests/component_tests/test_components.py b/evalml/tests/component_tests/test_components.py
index 98dc0bb0..0e8552d5 100644
--- a/evalml/tests/component_tests/test_components.py
+++ b/evalml/tests/component_tests/test_components.py
@@ -143,6 +143,7 @@ def test_describe_component():
     text_featurizer = TextFeaturizer()
     lsa = LSA()
     assert enc.describe(return_dict=True) == {'name': 'One Hot Encoder', 'parameters': {'top_n': 10,
+                                                                                        'features_to_encode': None,
                                                                                         'categories': None,
                                                                                         'drop': None,
                                                                                         'handle_unknown': 'ignore',
diff --git a/evalml/tests/component_tests/test_one_hot_encoder.py b/evalml/tests/component_tests/test_one_hot_encoder.py
index ef7d3308..b0fd4715 100644
--- a/evalml/tests/component_tests/test_one_hot_encoder.py
+++ b/evalml/tests/component_tests/test_one_hot_encoder.py
@@ -8,11 +8,12 @@ from evalml.utils import get_random_state
 
 
 def test_init():
-    parameters = {"top_n": 10,
-                  "categories": None,
-                  "drop": None,
-                  "handle_unknown": "ignore",
-                  "handle_missing": "error"}
+    parameters = {'top_n': 10,
+                  'features_to_encode': None,
+                  'categories': None,
+                  'drop': None,
+                  'handle_unknown': 'ignore',
+                  'handle_missing': 'error'}
     encoder = OneHotEncoder()
     assert encoder.parameters == parameters
 
@@ -21,6 +22,7 @@ def test_parameters():
     encoder = OneHotEncoder(top_n=123)
     expected_parameters = {
         'top_n': 123,
+        'features_to_encode': None,
         'categories': None,
         'drop': None,
         'handle_unknown': 'ignore',
@@ -381,3 +383,48 @@ def test_ohe_get_feature_names():
         ohe.get_feature_names()
     ohe.fit(X)
     np.testing.assert_array_equal(ohe.get_feature_names(), np.array(['col_1_a', 'col_2_a', 'col_2_b']))
+
+
+def test_ohe_features_to_encode():
+    # Test feature that doesn't need encoding and
+    # feature that needs encoding but is not specified remain untouched
+    X = pd.DataFrame({"col_1": [2, 0, 1, 0, 0],
+                      "col_2": ['a', 'b', 'a', 'c', 'd']})
+
+    encoder = OneHotEncoder(top_n=5, features_to_encode=['col_1'])
+    encoder.fit(X)
+    X_t = encoder.transform(X)
+    expected_col_names = set(['col_1_0', 'col_1_1', 'col_1_2', 'col_2'])
+    col_names = set(X_t.columns)
+    assert (col_names == expected_col_names)
+    assert ([X_t[col].dtype == "uint8" for col in X_t])
+
+    encoder = OneHotEncoder(top_n=5, features_to_encode=['col_1', 'col_2'])
+    encoder.fit(X)
+    X_t = encoder.transform(X)
+    expected_col_names = set(['col_1_0', 'col_1_1', 'col_1_2',
+                              'col_2_a', 'col_2_b', 'col_2_c', 'col_2_d'])
+    col_names = set(X_t.columns)
+    assert (col_names == expected_col_names)
+    assert ([X_t[col].dtype == "uint8" for col in X_t])
+
+
+def test_ohe_features_to_encode_col_missing():
+    X = pd.DataFrame({"col_1": [2, 0, 1, 0, 0],
+                      "col_2": ['a', 'b', 'a', 'c', 'd']})
+
+    encoder = OneHotEncoder(top_n=5, features_to_encode=['col_3', 'col_4'])
+
+    with pytest.raises(ValueError, match="Could not find and encode"):
+        encoder.fit(X)
+
+
+def test_ohe_features_to_encode_no_col_names():
+    X = pd.DataFrame([["b", 0], ["a", 1]])
+    encoder = OneHotEncoder(top_n=5, features_to_encode=[0])
+    encoder.fit(X)
+    X_t = encoder.transform(X)
+    expected_col_names = set([1, "0_a", "0_b"])
+    col_names = set(X_t.columns)
+    assert (col_names == expected_col_names)
+    assert ([X_t[col].dtype == "uint8" for col in X_t])
diff --git a/evalml/tests/pipeline_tests/test_pipelines.py b/evalml/tests/pipeline_tests/test_pipelines.py
index 4218063c..5911b1cc 100644
--- a/evalml/tests/pipeline_tests/test_pipelines.py
+++ b/evalml/tests/pipeline_tests/test_pipelines.py
@@ -463,6 +463,7 @@ def test_parameters(logistic_regression_binary_pipeline_class):
         },
         'One Hot Encoder': {
             'top_n': 10,
+            'features_to_encode': None,
             'categories': None,
             'drop': None,
             'handle_unknown': 'ignore',
@@ -1085,6 +1086,7 @@ def test_get_default_parameters(logistic_regression_binary_pipeline_class):
         },
         'One Hot Encoder': {
             'top_n': 10,
+            'features_to_encode': None,
             'categories': None,
             'drop': None,
             'handle_unknown': 'ignore',