Repository: solegalli/feature_engine

Issue:
one hot encoder has the option to automatically drop the second binary variable for binary categorical variables
We need to add one parameter to the init method of the one hot encoder, called for example automate_drop_last_binary that when True, will automatically return 1 binary variable if the original variable was binary.

Details:
- automate_drop_last_binary should default to False not to break backwards compatibility
- automate_drop_last_binary would only work for binary variables
- automate_drop_last_binary would only work if drop_last=False

When automate_drop_last_binary=True, binary variables would result in the addition of just one dummy instead of 1. So for the variable colour, with categories black and white, instead of returning the binary colour_black and colour_white, which are redundant, it would only return colour_black

In addition, we should add an attribute in the fit method called for example binary_variables_ where the transformer returns a list with the names of the binary variables.

To finish of this PR, we need to add 1-2 tests to make sure that the behaviour is the expected

Discussion:

Patch:
diff --git a/feature_engine/encoding/one_hot.py b/feature_engine/encoding/one_hot.py
index c952a80..a2e2736 100644
--- a/feature_engine/encoding/one_hot.py
+++ b/feature_engine/encoding/one_hot.py
@@ -77,7 +77,14 @@ class OneHotEncoder(BaseCategoricalTransformer):
     drop_last: boolean, default=False
         Only used if `top_categories = None`. It indicates whether to create dummy
         variables for all the categories (k dummies), or if set to `True`, it will
-        ignore the last binary variable of the list (k-1 dummies).
+        ignore the last binary variable and return k-1 dummies.
+
+    drop_last_binary: boolean, default=False
+        Whether to return 1 or 2 dummy variables for binary categorical variables. When
+        a categorical variable has only 2 categories, then the second dummy variable
+        created by one hot encoding can be completely redundant. Setting this parameter
+        to `True`, will ensure that for every binary variable in the dataset, only 1
+        dummy is created.
 
     variables: list, default=None
         The list of categorical variables that will be encoded. If None, the
@@ -100,6 +107,10 @@ class OneHotEncoder(BaseCategoricalTransformer):
     variables_:
         The group of variables that will be transformed.
 
+    variables_binary_:
+        A list with binary variables identified from the data. That is, variables with
+        only 2 categories.
+
     n_features_in_:
         The number of features in the train set used in fit.
 
@@ -135,6 +146,7 @@ class OneHotEncoder(BaseCategoricalTransformer):
         self,
         top_categories: Optional[int] = None,
         drop_last: bool = False,
+        drop_last_binary: bool = False,
         variables: Union[None, int, str, List[Union[str, int]]] = None,
         ignore_format: bool = False,
     ) -> None:
@@ -145,11 +157,15 @@ class OneHotEncoder(BaseCategoricalTransformer):
         if not isinstance(drop_last, bool):
             raise ValueError("drop_last takes only True or False")
 
+        if not isinstance(drop_last_binary, bool):
+            raise ValueError("drop_last_binary takes only True or False")
+
         if not isinstance(ignore_format, bool):
             raise ValueError("ignore_format takes only booleans True and False")
 
         self.top_categories = top_categories
         self.drop_last = drop_last
+        self.drop_last_binary = drop_last_binary
         self.variables = _check_input_parameter_variables(variables)
         self.ignore_format = ignore_format
 
@@ -188,15 +204,9 @@ class OneHotEncoder(BaseCategoricalTransformer):
 
         self.encoder_dict_ = {}
 
-        for var in self.variables_:
-            if not self.top_categories:
-                if self.drop_last:
-                    category_ls = [x for x in X[var].unique()]
-                    self.encoder_dict_[var] = category_ls[:-1]
-                else:
-                    self.encoder_dict_[var] = X[var].unique()
-
-            else:
+        # make dummies only for the most popular categories
+        if self.top_categories:
+            for var in self.variables_:
                 self.encoder_dict_[var] = [
                     x
                     for x in X[var]
@@ -206,6 +216,27 @@ class OneHotEncoder(BaseCategoricalTransformer):
                     .index
                 ]
 
+        else:
+            # return k-1 dummies
+            if self.drop_last:
+                for var in self.variables_:
+                    category_ls = [x for x in X[var].unique()]
+                    self.encoder_dict_[var] = category_ls[:-1]
+
+            # return k dummies
+            else:
+                for var in self.variables_:
+                    self.encoder_dict_[var] = X[var].unique()
+
+        self.variables_binary_ = [
+            var for var in self.variables_ if X[var].nunique() == 2
+        ]
+
+        # automatically encode binary variables as 1 dummy
+        if self.drop_last_binary:
+            for var in self.variables_binary_:
+                self.encoder_dict_[var] = X[var].unique()[0]
+
         self._check_encoding_dictionary()
 
         self.n_features_in_ = X.shape[1]


Test patch:
diff --git a/tests/test_encoding/test_onehot_encoder.py b/tests/test_encoding/test_onehot_encoder.py
index 4f6b50f..d2eb5b7 100644
--- a/tests/test_encoding/test_onehot_encoder.py
+++ b/tests/test_encoding/test_onehot_encoder.py
@@ -141,7 +141,6 @@ def test_transform_raises_error_if_df_contains_na(df_enc_big, df_enc_big_na):
 
 
 def test_encode_numerical_variables(df_enc_numeric):
-
     encoder = OneHotEncoder(
         top_categories=None,
         variables=None,
@@ -171,7 +170,6 @@ def test_encode_numerical_variables(df_enc_numeric):
 
 
 def test_variables_cast_as_category(df_enc_numeric):
-
     encoder = OneHotEncoder(
         top_categories=None,
         variables=None,
@@ -201,3 +199,121 @@ def test_variables_cast_as_category(df_enc_numeric):
     assert encoder.n_features_in_ == 2
     # test transform output
     pd.testing.assert_frame_equal(X, transf)
+
+
+@pytest.fixture(scope="module")
+def df_enc_binary():
+    df = {
+        "var_A": ["A"] * 6 + ["B"] * 10 + ["C"] * 4,
+        "var_B": ["A"] * 10 + ["B"] * 6 + ["C"] * 4,
+        "var_C": ["A"] * 10 + ["B"] * 10,
+        "target": [1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 1, 1, 0, 0],
+    }
+    df = pd.DataFrame(df)
+
+    return df
+
+
+def test_encode_into_k_binary_plus_drop_binary(df_enc_binary):
+    encoder = OneHotEncoder(
+        top_categories=None, variables=None, drop_last=False, drop_last_binary=True
+    )
+    X = encoder.fit_transform(df_enc_binary)
+
+    # test fit attr
+    transf = {
+        "target": [1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 1, 1, 0, 0],
+        "var_A_A": [1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
+        "var_A_B": [0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0],
+        "var_A_C": [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1],
+        "var_B_A": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
+        "var_B_B": [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0],
+        "var_B_C": [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1],
+        "var_C_A": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
+    }
+
+    transf = pd.DataFrame(transf).astype("int32")
+    X = pd.DataFrame(X).astype("int32")
+
+    assert encoder.variables_ == ["var_A", "var_B", "var_C"]
+    assert encoder.n_features_in_ == 4
+    # test transform output
+    pd.testing.assert_frame_equal(X, transf)
+    assert "var_C_B" not in X.columns
+
+
+def test_encode_into_kminus1_binary_plus_drop_binary(df_enc_binary):
+    encoder = OneHotEncoder(
+        top_categories=None, variables=None, drop_last=True, drop_last_binary=True
+    )
+    X = encoder.fit_transform(df_enc_binary)
+
+    # test fit attr
+    transf = {
+        "target": [1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 1, 1, 0, 0],
+        "var_A_A": [1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
+        "var_A_B": [0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0],
+        "var_B_A": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
+        "var_B_B": [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0],
+        "var_C_A": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
+    }
+
+    transf = pd.DataFrame(transf).astype("int32")
+    X = pd.DataFrame(X).astype("int32")
+
+    assert encoder.variables_ == ["var_A", "var_B", "var_C"]
+    assert encoder.n_features_in_ == 4
+    # test transform output
+    pd.testing.assert_frame_equal(X, transf)
+    assert "var_C_B" not in X.columns
+
+
+def test_encode_into_top_categories_plus_drop_binary(df_enc_binary):
+
+    # top_categories = 1
+    encoder = OneHotEncoder(
+        top_categories=1, variables=None, drop_last=False, drop_last_binary=True
+    )
+    X = encoder.fit_transform(df_enc_binary)
+
+    # test fit attr
+    transf = {
+        "target": [1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 1, 1, 0, 0],
+        "var_A_B": [0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0],
+        "var_B_A": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
+        "var_C_A": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
+    }
+
+    transf = pd.DataFrame(transf).astype("int32")
+    X = pd.DataFrame(X).astype("int32")
+
+    assert encoder.variables_ == ["var_A", "var_B", "var_C"]
+    assert encoder.n_features_in_ == 4
+    # test transform output
+    pd.testing.assert_frame_equal(X, transf)
+    assert "var_C_B" not in X.columns
+
+    # top_categories = 2
+    encoder = OneHotEncoder(
+        top_categories=2, variables=None, drop_last=False, drop_last_binary=True
+    )
+    X = encoder.fit_transform(df_enc_binary)
+
+    # test fit attr
+    transf = {
+        "target": [1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 1, 1, 0, 0],
+        "var_A_B": [0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0],
+        "var_A_A": [1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
+        "var_B_A": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
+        "var_B_B": [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0],
+        "var_C_A": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
+    }
+
+    transf = pd.DataFrame(transf).astype("int32")
+    X = pd.DataFrame(X).astype("int32")
+
+    assert encoder.variables_ == ["var_A", "var_B", "var_C"]
+    assert encoder.n_features_in_ == 4
+    # test transform output
+    pd.testing.assert_frame_equal(X, transf)
+    assert "var_C_B" not in X.columns