Repository: feature-engine/feature_engine (python)

Issue:
replace concat in encoders by y_train.groupby(X_train["variable"]).mean().sort_values()
Applies to the following encoders:

- OrdinalEncoder
- MeanEncoder
- PRatioEncoder
- WoEEncoder

Currently we concatenate the target to the predictors (X_train) to determine the mappings. This is not necessary. We can determined them automatically with syntax like this:

y_train.groupby(X_train["A7"]).mean().sort_values()

therefore removing one unnecessary step in the calculation, the pd.concat step

Pull request:
replace concat by group by target in encoders
more elegant,  simpler and, above all, faster implementation.

closes #427 

Patch:
diff --git a/feature_engine/encoding/mean_encoding.py b/feature_engine/encoding/mean_encoding.py
index 0816dee..40b9ad9 100644
--- a/feature_engine/encoding/mean_encoding.py
+++ b/feature_engine/encoding/mean_encoding.py
@@ -141,13 +141,10 @@ class MeanEncoder(CategoricalInitExpandedMixin, CategoricalMethodsMixin):
         self._fit(X)
         self._get_feature_names_in(X)
 
-        temp = pd.concat([X, y], axis=1)
-        temp.columns = list(X.columns) + ["target"]
-
         self.encoder_dict_ = {}
 
         for var in self.variables_:
-            self.encoder_dict_[var] = temp.groupby(var)["target"].mean().to_dict()
+            self.encoder_dict_[var] = y.groupby(X[var]).mean().to_dict()
 
         self._check_encoding_dictionary()
 
diff --git a/feature_engine/encoding/ordinal.py b/feature_engine/encoding/ordinal.py
index 4d6f6d3..5c9069f 100644
--- a/feature_engine/encoding/ordinal.py
+++ b/feature_engine/encoding/ordinal.py
@@ -160,22 +160,14 @@ class OrdinalEncoder(CategoricalInitExpandedMixin, CategoricalMethodsMixin):
         self._fit(X)
         self._get_feature_names_in(X)
 
-        if self.encoding_method == "ordered":
-            temp = pd.concat([X, y], axis=1)
-            temp.columns = list(X.columns) + ["target"]
-
         # find mappings
         self.encoder_dict_ = {}
 
         for var in self.variables_:
 
             if self.encoding_method == "ordered":
-                t = (
-                    temp.groupby([var])["target"]
-                    .mean()
-                    .sort_values(ascending=True)
-                    .index
-                )
+                t = y.groupby(X[var]).mean()  # type: ignore
+                t = t.sort_values(ascending=True).index
 
             elif self.encoding_method == "arbitrary":
                 t = X[var].unique()
diff --git a/feature_engine/encoding/woe.py b/feature_engine/encoding/woe.py
index f151f50..11a9098 100644
--- a/feature_engine/encoding/woe.py
+++ b/feature_engine/encoding/woe.py
@@ -151,40 +151,33 @@ class WoEEncoder(CategoricalInitExpandedMixin, CategoricalMethodsMixin):
                 "used has more than 2 unique values."
             )
 
-        self._fit(X)
-        self._get_feature_names_in(X)
-
-        temp = pd.concat([X, y], axis=1)
-        temp.columns = list(X.columns) + ["target"]
-
         # if target does not have values 0 and 1, we need to remap, to be able to
         # compute the averages.
-        if any(x for x in y.unique() if x not in [0, 1]):
-            temp["target"] = np.where(temp["target"] == y.unique()[0], 0, 1)
+        if y.min() != 0 or y.max() != 1:
+            y = pd.Series(np.where(y == y.min(), 0, 1))
+
+        self._fit(X)
+        self._get_feature_names_in(X)
 
         self.encoder_dict_ = {}
 
-        total_pos = temp["target"].sum()
-        total_neg = len(temp) - total_pos
-        temp["non_target"] = np.where(temp["target"] == 1, 0, 1)
+        total_pos = y.sum()
+        inverse_y = y.ne(1).copy()
+        total_neg = inverse_y.sum()
 
         for var in self.variables_:
-            pos = temp.groupby([var])["target"].sum() / total_pos
-            neg = temp.groupby([var])["non_target"].sum() / total_neg
+            pos = y.groupby(X[var]).sum() / total_pos
+            neg = inverse_y.groupby(X[var]).sum() / total_neg
 
-            t = pd.concat([pos, neg], axis=1)
-            t["woe"] = np.log(t["target"] / t["non_target"])
-
-            if (
-                not t.loc[t["target"] == 0, :].empty
-                or not t.loc[t["non_target"] == 0, :].empty
-            ):
+            if not (pos[:] == 0).sum() == 0 or not (neg[:] == 0).sum() == 0:
                 raise ValueError(
                     "The proportion of one of the classes for a category in "
                     "variable {} is zero, and log of zero is not defined".format(var)
                 )
 
-            self.encoder_dict_[var] = t["woe"].to_dict()
+            woe = np.log(pos / neg)
+
+            self.encoder_dict_[var] = woe.to_dict()
 
         self._check_encoding_dictionary()
 


Test patch:
diff --git a/tests/test_encoding/test_count_frequency_encoder.py b/tests/test_encoding/test_count_frequency_encoder.py
index a4b88f8..f7ede51 100644
--- a/tests/test_encoding/test_count_frequency_encoder.py
+++ b/tests/test_encoding/test_count_frequency_encoder.py
@@ -194,14 +194,12 @@ def test_transform_raises_error_if_df_contains_na(errors, df_enc, df_enc_na):
 
 
 def test_zero_encoding_for_new_categories():
-    df_fit = pd.DataFrame({
-        "col1": ["a", "a", "b", "a", "c"],
-        "col2": ["1", "2", "3", "1", "2"]
-    })
-    df_transf = pd.DataFrame({
-        "col1": ["a", "d", "b", "a", "c"],
-        "col2": ["1", "2", "3", "1", "4"]
-    })
+    df_fit = pd.DataFrame(
+        {"col1": ["a", "a", "b", "a", "c"], "col2": ["1", "2", "3", "1", "2"]}
+    )
+    df_transf = pd.DataFrame(
+        {"col1": ["a", "d", "b", "a", "c"], "col2": ["1", "2", "3", "1", "4"]}
+    )
     encoder = CountFrequencyEncoder(errors="encode").fit(df_fit)
     result = encoder.transform(df_transf)
 
@@ -209,10 +207,7 @@ def test_zero_encoding_for_new_categories():
     assert pd.isnull(result).sum().sum() == 0
 
     # check that the counts are correct for both new and old
-    expected_result = pd.DataFrame({
-        "col1": [3, 0, 1, 3, 1],
-        "col2": [2, 2, 1, 2, 0]
-    })
+    expected_result = pd.DataFrame({"col1": [3, 0, 1, 3, 1], "col2": [2, 2, 1, 2, 0]})
     pd.testing.assert_frame_equal(result, expected_result)
 
 
diff --git a/tests/test_encoding/test_woe_encoder.py b/tests/test_encoding/test_woe_encoder.py
index 64eeca4..3ed5da5 100644
--- a/tests/test_encoding/test_woe_encoder.py
+++ b/tests/test_encoding/test_woe_encoder.py
@@ -3,65 +3,63 @@ import pytest
 
 from feature_engine.encoding import WoEEncoder
 
+VAR_A = [
+    0.15415067982725836,
+    0.15415067982725836,
+    0.15415067982725836,
+    0.15415067982725836,
+    0.15415067982725836,
+    0.15415067982725836,
+    -0.5389965007326869,
+    -0.5389965007326869,
+    -0.5389965007326869,
+    -0.5389965007326869,
+    -0.5389965007326869,
+    -0.5389965007326869,
+    -0.5389965007326869,
+    -0.5389965007326869,
+    -0.5389965007326869,
+    -0.5389965007326869,
+    0.8472978603872037,
+    0.8472978603872037,
+    0.8472978603872037,
+    0.8472978603872037,
+]
+
+VAR_B = [
+    -0.5389965007326869,
+    -0.5389965007326869,
+    -0.5389965007326869,
+    -0.5389965007326869,
+    -0.5389965007326869,
+    -0.5389965007326869,
+    -0.5389965007326869,
+    -0.5389965007326869,
+    -0.5389965007326869,
+    -0.5389965007326869,
+    0.15415067982725836,
+    0.15415067982725836,
+    0.15415067982725836,
+    0.15415067982725836,
+    0.15415067982725836,
+    0.15415067982725836,
+    0.8472978603872037,
+    0.8472978603872037,
+    0.8472978603872037,
+    0.8472978603872037,
+]
 
-def test_automatically_select_variables(df_enc):
 
-    # test case 1: automatically select variables, woe
+def test_automatically_select_variables(df_enc):
     encoder = WoEEncoder(variables=None)
     encoder.fit(df_enc[["var_A", "var_B"]], df_enc["target"])
     X = encoder.transform(df_enc[["var_A", "var_B"]])
 
     # transformed dataframe
     transf_df = df_enc.copy()
-    transf_df["var_A"] = [
-        0.15415067982725836,
-        0.15415067982725836,
-        0.15415067982725836,
-        0.15415067982725836,
-        0.15415067982725836,
-        0.15415067982725836,
-        -0.5389965007326869,
-        -0.5389965007326869,
-        -0.5389965007326869,
-        -0.5389965007326869,
-        -0.5389965007326869,
-        -0.5389965007326869,
-        -0.5389965007326869,
-        -0.5389965007326869,
-        -0.5389965007326869,
-        -0.5389965007326869,
-        0.8472978603872037,
-        0.8472978603872037,
-        0.8472978603872037,
-        0.8472978603872037,
-    ]
-    transf_df["var_B"] = [
-        -0.5389965007326869,
-        -0.5389965007326869,
-        -0.5389965007326869,
-        -0.5389965007326869,
-        -0.5389965007326869,
-        -0.5389965007326869,
-        -0.5389965007326869,
-        -0.5389965007326869,
-        -0.5389965007326869,
-        -0.5389965007326869,
-        0.15415067982725836,
-        0.15415067982725836,
-        0.15415067982725836,
-        0.15415067982725836,
-        0.15415067982725836,
-        0.15415067982725836,
-        0.8472978603872037,
-        0.8472978603872037,
-        0.8472978603872037,
-        0.8472978603872037,
-    ]
+    transf_df["var_A"] = VAR_A
+    transf_df["var_B"] = VAR_B
 
-    # init params
-    assert encoder.variables is None
-    # fit params
-    assert encoder.variables_ == ["var_A", "var_B"]
     assert encoder.encoder_dict_ == {
         "var_A": {
             "A": 0.15415067982725836,
@@ -74,11 +72,68 @@ def test_automatically_select_variables(df_enc):
             "C": 0.8472978603872037,
         },
     }
-    assert encoder.n_features_in_ == 2
-    # transform params
     pd.testing.assert_frame_equal(X, transf_df[["var_A", "var_B"]])
 
 
+def test_user_passes_variables(df_enc):
+    encoder = WoEEncoder(variables=["var_A", "var_B"])
+    encoder.fit(df_enc, df_enc["target"])
+    X = encoder.transform(df_enc)
+
+    # transformed dataframe
+    transf_df = df_enc.copy()
+    transf_df["var_A"] = VAR_A
+    transf_df["var_B"] = VAR_B
+
+    assert encoder.encoder_dict_ == {
+        "var_A": {
+            "A": 0.15415067982725836,
+            "B": -0.5389965007326869,
+            "C": 0.8472978603872037,
+        },
+        "var_B": {
+            "A": -0.5389965007326869,
+            "B": 0.15415067982725836,
+            "C": 0.8472978603872037,
+        },
+    }
+    pd.testing.assert_frame_equal(X, transf_df)
+
+
+_targets = [
+    [2, 2, 0, 0, 0, 0, 0, 0, 0, 0, 2, 2, 0, 0, 0, 0, 2, 2, 0, 0],
+    [1, 1, -1, -1, -1, -1, -1, -1, -1, -1, 1, 1, -1, -1, -1, -1, 1, 1, -1, -1],
+    [2, 2, 1, 1, 1, 1, 1, 1, 1, 1, 2, 2, 1, 1, 1, 1, 2, 2, 1, 1],
+]
+
+
+@pytest.mark.parametrize("target", _targets)
+def test_when_target_class_not_0_1(df_enc, target):
+    encoder = WoEEncoder(variables=["var_A", "var_B"])
+    df_enc["target"] = target
+    encoder.fit(df_enc, df_enc["target"])
+    X = encoder.transform(df_enc)
+
+    # transformed dataframe
+    transf_df = df_enc.copy()
+    transf_df["var_A"] = VAR_A
+    transf_df["var_B"] = VAR_B
+
+    assert encoder.encoder_dict_ == {
+        "var_A": {
+            "A": 0.15415067982725836,
+            "B": -0.5389965007326869,
+            "C": 0.8472978603872037,
+        },
+        "var_B": {
+            "A": -0.5389965007326869,
+            "B": 0.15415067982725836,
+            "C": 0.8472978603872037,
+        },
+    }
+    pd.testing.assert_frame_equal(X, transf_df)
+
+
 def test_warn_if_transform_df_contains_categories_not_seen_in_fit(df_enc, df_enc_rare):
     # test case 3: when dataset to be transformed contains categories not present
     # in training dataset
@@ -159,13 +214,12 @@ def test_error_if_contains_na_in_fit(df_enc_na):
 def test_error_if_df_contains_na_in_transform(df_enc, df_enc_na):
     # test case 10: when dataset contains na, transform method}
     encoder = WoEEncoder(variables=None)
+    encoder.fit(df_enc[["var_A", "var_B"]], df_enc["target"])
     with pytest.raises(ValueError):
-        encoder.fit(df_enc[["var_A", "var_B"]], df_enc["target"])
         encoder.transform(df_enc_na)
 
 
 def test_on_numerical_variables(df_enc_numeric):
-
     # ignore_format=True
     encoder = WoEEncoder(variables=None, ignore_format=True)
     encoder.fit(df_enc_numeric[["var_A", "var_B"]], df_enc_numeric["target"])
@@ -173,50 +227,8 @@ def test_on_numerical_variables(df_enc_numeric):
 
     # transformed dataframe
     transf_df = df_enc_numeric.copy()
-    transf_df["var_A"] = [
-        0.15415067982725836,
-        0.15415067982725836,
-        0.15415067982725836,
-        0.15415067982725836,
-        0.15415067982725836,
-        0.15415067982725836,
-        -0.5389965007326869,
-        -0.5389965007326869,
-        -0.5389965007326869,
-        -0.5389965007326869,
-        -0.5389965007326869,
-        -0.5389965007326869,
-        -0.5389965007326869,
-        -0.5389965007326869,
-        -0.5389965007326869,
-        -0.5389965007326869,
-        0.8472978603872037,
-        0.8472978603872037,
-        0.8472978603872037,
-        0.8472978603872037,
-    ]
-    transf_df["var_B"] = [
-        -0.5389965007326869,
-        -0.5389965007326869,
-        -0.5389965007326869,
-        -0.5389965007326869,
-        -0.5389965007326869,
-        -0.5389965007326869,
-        -0.5389965007326869,
-        -0.5389965007326869,
-        -0.5389965007326869,
-        -0.5389965007326869,
-        0.15415067982725836,
-        0.15415067982725836,
-        0.15415067982725836,
-        0.15415067982725836,
-        0.15415067982725836,
-        0.15415067982725836,
-        0.8472978603872037,
-        0.8472978603872037,
-        0.8472978603872037,
-        0.8472978603872037,
-    ]
+    transf_df["var_A"] = VAR_A
+    transf_df["var_B"] = VAR_B
 
     # init params
     assert encoder.variables is None
@@ -247,50 +259,8 @@ def test_variables_cast_as_category(df_enc_category_dtypes):
 
     # transformed dataframe
     transf_df = df.copy()
-    transf_df["var_A"] = [
-        0.15415067982725836,
-        0.15415067982725836,
-        0.15415067982725836,
-        0.15415067982725836,
-        0.15415067982725836,
-        0.15415067982725836,
-        -0.5389965007326869,
-        -0.5389965007326869,
-        -0.5389965007326869,
-        -0.5389965007326869,
-        -0.5389965007326869,
-        -0.5389965007326869,
-        -0.5389965007326869,
-        -0.5389965007326869,
-        -0.5389965007326869,
-        -0.5389965007326869,
-        0.8472978603872037,
-        0.8472978603872037,
-        0.8472978603872037,
-        0.8472978603872037,
-    ]
-    transf_df["var_B"] = [
-        -0.5389965007326869,
-        -0.5389965007326869,
-        -0.5389965007326869,
-        -0.5389965007326869,
-        -0.5389965007326869,
-        -0.5389965007326869,
-        -0.5389965007326869,
-        -0.5389965007326869,
-        -0.5389965007326869,
-        -0.5389965007326869,
-        0.15415067982725836,
-        0.15415067982725836,
-        0.15415067982725836,
-        0.15415067982725836,
-        0.15415067982725836,
-        0.15415067982725836,
-        0.8472978603872037,
-        0.8472978603872037,
-        0.8472978603872037,
-        0.8472978603872037,
-    ]
+    transf_df["var_A"] = VAR_A
+    transf_df["var_B"] = VAR_B
 
     pd.testing.assert_frame_equal(X, transf_df[["var_A", "var_B"]], check_dtype=False)
     assert X["var_A"].dtypes == float