Repository: feature-engine/feature_engine (python)

Issue:
RelativeFeatures: add option not to fail when dividing by zero and instead fill inf values with something

Pull request description:
add fill_value to RelativeFeatures for when dividing by zero
closes #653 
closes #577 

FYI @Morgan-Sell 

Making this PR due to rebase conflicts




Patch:
diff --git a/docs/index.rst b/docs/index.rst
index e121856..c2e3088 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -267,4 +267,5 @@ Table of Contents
    contribute/index
    about/index
    whats_new/index
+   versions/index
    donate
diff --git a/docs/versions/index.rst b/docs/versions/index.rst
new file mode 100644
index 0000000..be193f0
--- /dev/null
+++ b/docs/versions/index.rst
@@ -0,0 +1,12 @@
+Other versions
+==============
+
+Web-based documentation is available for versions listed below:
+
+- `Feature-engine 1.6 <https://feature-engine.trainindata.com/en/1.6.x/index.html>`_
+- `Feature-engine 1.5 <https://feature-engine.trainindata.com/en/1.5.x/index.html>`_
+- `Feature-engine 1.4 <https://feature-engine.trainindata.com/en/1.4.x/index.html>`_
+- `Feature-engine 1.3 <https://feature-engine.trainindata.com/en/1.3.x/index.html>`_
+- `Feature-engine 1.2 <https://feature-engine.trainindata.com/en/1.2.x/index.html>`_
+- `Feature-engine 1.1 <https://feature-engine.trainindata.com/en/1.1.x/index.html>`_
+- `Feature-engine 0.6 <https://feature-engine.trainindata.com/en/0.6.x_a/>`_
diff --git a/feature_engine/creation/relative_features.py b/feature_engine/creation/relative_features.py
index 55aa277..b8ee009 100644
--- a/feature_engine/creation/relative_features.py
+++ b/feature_engine/creation/relative_features.py
@@ -77,6 +77,10 @@ class RelativeFeatures(BaseCreation):
         one or more of the following strings: 'add', 'mul','sub', 'div', truediv,
         'floordiv', 'mod', 'pow'.
 
+    fill_value: int, float, default=None
+        When dividing by zero, this value is used in place of infinity. If None,
+        then an error will be raised when dividing by zero.
+
     {missing_values}
 
     {drop_original}
@@ -128,6 +132,7 @@ class RelativeFeatures(BaseCreation):
         variables: List[Union[str, int]],
         reference: List[Union[str, int]],
         func: List[str],
+        fill_value: Union[int, float, None] = None,
         missing_values: str = "ignore",
         drop_original: bool = False,
     ) -> None:
@@ -163,10 +168,16 @@ class RelativeFeatures(BaseCreation):
                 "Supported functions are {}. ".format(", ".join(_PERMITTED_FUNCTIONS))
             )
 
+        if fill_value is not None and not isinstance(fill_value, (float, int)):
+            raise ValueError(
+                "fill_value must be a float, integer or None. "
+                f"Got {fill_value} instead."
+            )
         super().__init__(missing_values, drop_original)
         self.variables = variables
         self.reference = reference
         self.func = func
+        self.fill_value = fill_value
 
     def transform(self, X: pd.DataFrame) -> pd.DataFrame:
         """
@@ -213,17 +224,6 @@ class RelativeFeatures(BaseCreation):
             X[varname] = X[self.variables].sub(X[reference], axis=0)
         return X
 
-    def _div(self, X):
-        for reference in self.reference:
-            if (X[reference] == 0).any():
-                raise ValueError(
-                    "Some of the reference variables contain 0 as values. Check and "
-                    "remove those before using this transformer."
-                )
-            varname = [f"{var}_div_{reference}" for var in self.variables]
-            X[varname] = X[self.variables].div(X[reference], axis=0)
-        return X
-
     def _add(self, X):
         for reference in self.reference:
             varname = [f"{var}_add_{reference}" for var in self.variables]
@@ -236,38 +236,60 @@ class RelativeFeatures(BaseCreation):
             X[varname] = X[self.variables].mul(X[reference], axis=0)
         return X
 
-    def _truediv(self, X):
+    def _div(self, X):
+        for reference in self.reference:
+            zeros_ix, contains_zero = self._find_zeroes_in_reference(X, reference)
 
+            if self.fill_value is None and contains_zero:
+                self._raise_error_when_zero_in_denominator()
+
+            varname = [f"{var}_div_{reference}" for var in self.variables]
+            X[varname] = X[self.variables].div(X[reference], axis=0)
+
+            if contains_zero:
+                X.loc[zeros_ix, varname] = self.fill_value
+        return X
+
+    def _truediv(self, X):
         for reference in self.reference:
-            if (X[reference] == 0).any():
-                raise ValueError(
-                    "Some of the reference variables contain 0 as values. Check and "
-                    "remove those before using this transformer."
-                )
+            zeros_ix, contains_zero = self._find_zeroes_in_reference(X, reference)
+
+            if self.fill_value is None and contains_zero:
+                self._raise_error_when_zero_in_denominator()
+
             varname = [f"{var}_truediv_{reference}" for var in self.variables]
             X[varname] = X[self.variables].truediv(X[reference], axis=0)
+
+            if contains_zero:
+                X.loc[zeros_ix, varname] = self.fill_value
         return X
 
     def _floordiv(self, X):
         for reference in self.reference:
-            if (X[reference] == 0).any():
-                raise ValueError(
-                    "Some of the reference variables contain 0 as values. Check and "
-                    "remove those before using this transformer."
-                )
+            zeros_ix, contains_zero = self._find_zeroes_in_reference(X, reference)
+
+            if self.fill_value is None and contains_zero:
+                self._raise_error_when_zero_in_denominator()
+
             varname = [f"{var}_floordiv_{reference}" for var in self.variables]
             X[varname] = X[self.variables].floordiv(X[reference], axis=0)
+
+            if contains_zero:
+                X.loc[zeros_ix, varname] = self.fill_value
         return X
 
     def _mod(self, X):
         for reference in self.reference:
-            if (X[reference] == 0).any():
-                raise ValueError(
-                    "Some of the reference variables contain 0 as values. Check and "
-                    "remove those before using this transformer."
-                )
+            zeros_ix, contains_zero = self._find_zeroes_in_reference(X, reference)
+
+            if self.fill_value is None and contains_zero:
+                self._raise_error_when_zero_in_denominator()
+
             varname = [f"{var}_mod_{reference}" for var in self.variables]
             X[varname] = X[self.variables].mod(X[reference], axis=0)
+
+            if contains_zero:
+                X.loc[zeros_ix, varname] = self.fill_value
         return X
 
     def _pow(self, X):
@@ -276,6 +298,18 @@ class RelativeFeatures(BaseCreation):
             X[varname] = X[self.variables].pow(X[reference], axis=0)
         return X
 
+    def _raise_error_when_zero_in_denominator(self):
+        raise ValueError(
+            "Some of the reference variables contain zeroes. Division by zero "
+            "does not exist. Replace zeros before using this transformer for division "
+            "or set `fill_value` to a number."
+        )
+
+    def _find_zeroes_in_reference(self, X, var):
+        zero_ix = X[var] == 0
+        zero_bool = (zero_ix).any()
+        return zero_ix, zero_bool
+
     def _get_new_features_name(self) -> List:
         """Return names of the created features."""
 
diff --git a/feature_engine/encoding/rare_label.py b/feature_engine/encoding/rare_label.py
index 4859b2c..c7afb18 100644
--- a/feature_engine/encoding/rare_label.py
+++ b/feature_engine/encoding/rare_label.py
@@ -218,7 +218,7 @@ class RareLabelEncoder(CategoricalInitMixinNA, CategoricalMethodsMixin):
                     "indicated in n_categories. Thus, all categories will be "
                     "considered frequent".format(var)
                 )
-                self.encoder_dict_[var] = X[var].unique()
+                self.encoder_dict_[var] = list(X[var].unique())
 
         self.variables_ = variables_
         self._get_feature_names_in(X)
@@ -247,19 +247,19 @@ class RareLabelEncoder(CategoricalInitMixinNA, CategoricalMethodsMixin):
             _check_optional_contains_na(X, self.variables_)
 
             for feature in self.variables_:
-                X[feature] = np.where(
-                    X[feature].isin(self.encoder_dict_[feature]),
-                    X[feature],
-                    self.replace_with,
-                )
+                if X[feature].dtype == "category":
+                    X[feature] = X[feature].cat.add_categories(self.replace_with)
+                X.loc[
+                    ~X[feature].isin(self.encoder_dict_[feature]), feature
+                ] = self.replace_with
 
         else:
             for feature in self.variables_:
-                X[feature] = np.where(
-                    X[feature].isin(self.encoder_dict_[feature] + [np.nan]),
-                    X[feature],
-                    self.replace_with,
-                )
+                if X[feature].dtype == "category":
+                    X[feature] = X[feature].cat.add_categories(self.replace_with)
+                X.loc[
+                    ~X[feature].isin(self.encoder_dict_[feature] + [np.nan]), feature
+                ] = self.replace_with
 
         return X
 


Test patch:
diff --git a/tests/test_creation/test_relative_features.py b/tests/test_creation/test_relative_features.py
index d0db640..6ff77b2 100644
--- a/tests/test_creation/test_relative_features.py
+++ b/tests/test_creation/test_relative_features.py
@@ -53,6 +53,17 @@ def test_error_if_func_not_supported(_func):
         )
 
 
+@pytest.mark.parametrize("_fill_value", [(2, 3.3), ["test"], "python"])
+def test_error_if_fill_value_not_permitted(_fill_value):
+    with pytest.raises(ValueError):
+        RelativeFeatures(
+            variables=["Age"],
+            reference=["Marks"],
+            func=["sub", "div", "add", "mul"],
+            fill_value=_fill_value,
+        )
+
+
 def test_error_when_drop_original_not_bool():
     for drop_original in ["True", [True]]:
         with pytest.raises(ValueError):
@@ -318,7 +329,7 @@ def test_when_df_cols_are_integers(df_vartypes):
 
 
 @pytest.mark.parametrize("_func", [["div"], ["truediv"], ["floordiv"], ["mod"]])
-def test_error_when_division_by_zero(_func, df_vartypes):
+def test_error_when_division_by_zero_and_fill_value_is_none(_func, df_vartypes):
 
     df_zero = df_vartypes.copy()
     df_zero.loc[1, "Marks"] = 0
@@ -329,9 +340,53 @@ def test_error_when_division_by_zero(_func, df_vartypes):
         func=_func,
     )
     transformer.fit(df_vartypes)
-    with pytest.raises(ValueError):
+
+    with pytest.raises(ValueError) as record:
         transformer.transform(df_zero)
 
+    msg = (
+            "Some of the reference variables contain zeroes. Division by zero "
+            "does not exist. Replace zeros before using this transformer for division "
+            "or set `fill_value` to a number."
+        )
+    # check that the error message matches
+    assert str(record.value) == msg
+
+
+@pytest.mark.parametrize("_fill_value, _func", [
+    (111.111, ["div"]),
+    (999, ["div"]),
+    (111.111, ["truediv"]),
+    (999, ["truediv"]),
+    (111.111, ["floordiv"]),
+    (999, ["floordiv"]),
+    (111.111, ["mod"]),
+    (999, ["mod"]),
+])
+def test_fill_values_when_division_by_zero(
+        _fill_value, _func, df_vartypes
+):
+    df_zero = df_vartypes.copy()
+    df_zero.loc[2, "Marks"] = 0
+    df_zero.loc[1, "Age"] = np.nan
+    df_zero.loc[3, "Age"] = np.inf
+
+    transformer = RelativeFeatures(
+        variables=["Age"],
+        reference=["Marks"],
+        fill_value=_fill_value,
+        func=_func,
+        missing_values="ignore",
+    )
+
+    X = transformer.fit_transform(df_zero)
+
+    new_var = f"Age_{_func[0]}_Marks"
+
+    assert X.loc[2, new_var] == _fill_value
+    np.testing.assert_equal(X.loc[1, "Age"], np.nan)
+    np.testing.assert_equal(X.loc[3, "Age"], np.inf)
+
 
 @pytest.mark.parametrize("_drop", [True, False])
 def test_get_feature_names_out(_drop, df_vartypes):
diff --git a/tests/test_encoding/test_rare_label_encoder.py b/tests/test_encoding/test_rare_label_encoder.py
index 1a17553..ba85299 100644
--- a/tests/test_encoding/test_rare_label_encoder.py
+++ b/tests/test_encoding/test_rare_label_encoder.py
@@ -54,6 +54,34 @@ def test_defo_params_plus_automatically_find_variables(df_enc_big):
     pd.testing.assert_frame_equal(X, df)
 
 
+def test_when_varnames_are_numbers(df_enc_big):
+    input_df = df_enc_big.copy()
+    input_df.columns = [1, 2, 3]
+
+    encoder = RareLabelEncoder(
+        tol=0.06, n_categories=5, variables=None, replace_with="Rare"
+    )
+    X = encoder.fit_transform(input_df)
+
+    # expected output
+    df = {
+        1: ["A"] * 6 + ["B"] * 10 + ["C"] * 4 + ["D"] * 10 + ["Rare"] * 4 + ["G"] * 6,
+        2: ["A"] * 10 + ["B"] * 6 + ["C"] * 4 + ["D"] * 10 + ["Rare"] * 4 + ["G"] * 6,
+        3: ["A"] * 4 + ["B"] * 6 + ["C"] * 10 + ["D"] * 10 + ["Rare"] * 4 + ["G"] * 6,
+    }
+    df = pd.DataFrame(df)
+
+    frequenc_cat = {
+        1: ["B", "D", "A", "G", "C"],
+        2: ["A", "D", "B", "G", "C"],
+        3: ["C", "D", "B", "G", "A"],
+    }
+
+    assert encoder.variables_ == [1, 2, 3]
+    assert encoder.encoder_dict_ == frequenc_cat
+    pd.testing.assert_frame_equal(X, df)
+
+
 def test_correctly_ignores_nan_in_transform(df_enc_big):
     encoder = RareLabelEncoder(
         tol=0.06,
@@ -102,7 +130,7 @@ def test_correctly_ignores_nan_in_fit(df_enc_big):
         n_categories=3,
         missing_values="ignore",
     )
-    X = encoder.fit_transform(df)
+    encoder.fit(df)
 
     # expected:
     frequenc_cat = {
@@ -134,6 +162,90 @@ def test_correctly_ignores_nan_in_fit(df_enc_big):
     pd.testing.assert_frame_equal(X, tt)
 
 
+def test_correctly_ignores_nan_in_fit_when_var_is_numerical(df_enc_big):
+
+    df = df_enc_big.copy()
+    df["var_C"] = [
+        1,
+        1,
+        1,
+        1,
+        2,
+        2,
+        2,
+        2,
+        2,
+        2,
+        3,
+        3,
+        3,
+        3,
+        3,
+        3,
+        3,
+        3,
+        3,
+        3,
+        4,
+        4,
+        4,
+        4,
+        4,
+        4,
+        4,
+        4,
+        4,
+        4,
+        5,
+        5,
+        6,
+        6,
+        np.nan,
+        np.nan,
+        np.nan,
+        np.nan,
+        np.nan,
+        np.nan,
+    ]
+
+    encoder = RareLabelEncoder(
+        tol=0.06,
+        n_categories=3,
+        missing_values="ignore",
+        ignore_format=True,
+    )
+    encoder.fit(df)
+
+    # expected:
+    frequenc_cat = {
+        "var_A": ["B", "D", "A", "G", "C"],
+        "var_B": ["A", "D", "B", "G", "C"],
+        "var_C": [3, 4, 2, 1],
+    }
+    assert encoder.encoder_dict_ == frequenc_cat
+
+    # input
+    t = pd.DataFrame(
+        {
+            "var_A": ["A", np.nan, "J", "G"],
+            "var_B": ["A", np.nan, "J", "G"],
+            "var_C": [3, np.nan, 9, 10],
+        }
+    )
+
+    # expected
+    tt = pd.DataFrame(
+        {
+            "var_A": ["A", np.nan, "Rare", "G"],
+            "var_B": ["A", np.nan, "Rare", "G"],
+            "var_C": [3.0, np.nan, "Rare", "Rare"],
+        }
+    )
+
+    X = encoder.transform(t)
+    pd.testing.assert_frame_equal(X, tt, check_dtype=False)
+
+
 def test_user_provides_grouping_label_name_and_variable_list(df_enc_big):
     # test case 2: user provides alternative grouping value and variable list
     encoder = RareLabelEncoder(
@@ -316,12 +428,84 @@ def test_variables_cast_as_category(df_enc_big):
         + ["G"] * 6,
     }
     df = pd.DataFrame(df)
+    df["var_B"] = pd.Categorical(df["var_B"])
 
     # test fit attr
     assert encoder.variables_ == ["var_A", "var_B", "var_C"]
     assert encoder.n_features_in_ == 3
     # test transform output
-    pd.testing.assert_frame_equal(X, df)
+    pd.testing.assert_frame_equal(X, df, check_categorical=False)
+
+
+def test_variables_cast_as_category_with_na_in_transform(df_enc_big):
+    encoder = RareLabelEncoder(
+        tol=0.06,
+        n_categories=5,
+        variables=None,
+        replace_with="Rare",
+        missing_values="ignore",
+    )
+
+    df_enc_big = df_enc_big.copy()
+    df_enc_big["var_B"] = df_enc_big["var_B"].astype("category")
+    encoder.fit(df_enc_big)
+
+    # input
+    t = pd.DataFrame(
+        {
+            "var_A": ["A", np.nan, "J", "G"],
+            "var_B": ["A", np.nan, "J", "G"],
+            "var_C": ["A", np.nan, "J", "G"],
+        }
+    )
+    t["var_B"] = pd.Categorical(t["var_B"])
+
+    # expected
+    tt = pd.DataFrame(
+        {
+            "var_A": ["A", np.nan, "Rare", "G"],
+            "var_B": ["A", np.nan, "Rare", "G"],
+            "var_C": ["A", np.nan, "Rare", "G"],
+        }
+    )
+    tt["var_B"] = pd.Categorical(tt["var_B"])
+    pd.testing.assert_frame_equal(encoder.transform(t), tt, check_categorical=False)
+
+
+def test_variables_cast_as_category_with_na_in_fit(df_enc_big):
+
+    df = df_enc_big.copy()
+    df.loc[df["var_C"] == "G", "var_C"] = np.nan
+    df["var_C"] = df["var_C"].astype("category")
+
+    encoder = RareLabelEncoder(
+        tol=0.06,
+        n_categories=3,
+        missing_values="ignore",
+    )
+    encoder.fit(df)
+
+    # input
+    t = pd.DataFrame(
+        {
+            "var_A": ["A", np.nan, "J", "G"],
+            "var_B": ["A", np.nan, "J", "G"],
+            "var_C": ["C", np.nan, "J", "G"],
+        }
+    )
+    t["var_C"] = pd.Categorical(t["var_C"])
+
+    # expected
+    tt = pd.DataFrame(
+        {
+            "var_A": ["A", np.nan, "Rare", "G"],
+            "var_B": ["A", np.nan, "Rare", "G"],
+            "var_C": ["C", np.nan, "Rare", "Rare"],
+        }
+    )
+    tt["var_C"] = pd.Categorical(tt["var_C"])
+
+    pd.testing.assert_frame_equal(encoder.transform(t), tt, check_categorical=False)
 
 
 def test_inverse_transform_raises_not_implemented_error(df_enc_big):