diff --git a/docs/user_guide/encoding/WoEEncoder.rst b/docs/user_guide/encoding/WoEEncoder.rst index a25d83074..7b75e1a30 100644 --- a/docs/user_guide/encoding/WoEEncoder.rst +++ b/docs/user_guide/encoding/WoEEncoder.rst @@ -280,6 +280,41 @@ variable values: 686 -0.584173 female 22.000000 0 0 7.7250 -0.357528 0.012075 +With polars +~~~~~~~~~~~ + +:class:`WoEEncoder()` also works with polars dataframes: + +.. code:: python + + import polars as pl + from feature_engine.encoding import WoEEncoder + + X = pl.DataFrame(dict(x1 = [1,2,3,4,5], x2 = ["b", "b", "b", "a", "a"])) + y = pl.Series([0,1,1,1,0]) + + woe = WoEEncoder() + woe.fit(X, y) + woe.transform(X) + +We see the resulting dataframe below: + +.. code:: text + + shape: (5, 2) + ┌─────┬───────────┐ + │ x1 ┆ x2 │ + │ --- ┆ --- │ + │ i64 ┆ f64 │ + ╞═════╪═══════════╡ + │ 1 ┆ 0.287682 │ + │ 2 ┆ 0.287682 │ + │ 3 ┆ 0.287682 │ + │ 4 ┆ -0.405465 │ + │ 5 ┆ -0.405465 │ + └─────┴───────────┘ + + WoE in categorical and numerical variables ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/feature_engine/encoding/woe.py b/feature_engine/encoding/woe.py index bd1538a2c..4e203b62a 100644 --- a/feature_engine/encoding/woe.py +++ b/feature_engine/encoding/woe.py @@ -3,8 +3,10 @@ from typing import List, Union +import narwhals as nw +import narwhals.dependencies as nwd import numpy as np -import pandas as pd +from narwhals.typing import IntoDataFrame, IntoSeries from feature_engine._docstrings.fit_attributes import ( _feature_names_in_docstring, @@ -35,14 +37,36 @@ class WoE: - def _check_fit_input(self, X: pd.DataFrame, y: pd.Series): + def _check_fit_input(self, X: IntoDataFrame, y: IntoSeries): """ Check that X is dataframe, and y a binary series with values 0 and 1. """ - X, y = check_X_y(X, y) + nw_X, y = check_X_y(X, y) + + if nwd.is_into_series(y): + y_nw = nw.from_native(y, series_only=True) + else: + # y is a numpy array here (e.g. list/array-like y input, which + # sklearn's check_X_y machinery converts to numpy via + # column_or_1d) - it has no .nunique()/.groupby(), so wrap it + # against X's backend to get one consistent narwhals Series. + y_nw = nw.new_series( + name="target", + values=y, + backend=nw_X.implementation, + ) + if nwd.is_pandas_dataframe(X): + # new_series() gives pandas a fresh default RangeIndex, but + # _calculate_woe()'s y.groupby(X[var]) aligns the two + # Series by index - a mismatch against X's own index + # silently drops every row instead of raising, leaving + # encoder_dict_ empty. Line it up with X's index. + native_y = y_nw.to_native() + native_y.index = X.index + y_nw = nw.from_native(native_y, series_only=True) # check that y is binary - if y.nunique() != 2: + if y_nw.n_unique() != 2: raise ValueError( "This encoder is designed for binary classification. The target " "used has more than 2 unique values." @@ -50,14 +74,16 @@ def _check_fit_input(self, X: pd.DataFrame, y: pd.Series): # if target does not have values 0 and 1, we need to remap, to be able to # compute the averages. - if y.min() != 0 or y.max() != 1: - y = pd.Series(np.where(y == y.min(), 0, 1)) - return X, y + y_min, y_max = y_nw.min(), y_nw.max() + if y_min != 0 or y_max != 1: + y_nw = (y_nw != y_min).cast(nw.Int64()).alias("target") + + return X, y_nw.to_native() def _calculate_woe( self, - X: pd.DataFrame, - y: pd.Series, + X: IntoDataFrame, + y: IntoSeries, variable: Union[str, int], fill_value: Union[float, None] = None, ): @@ -198,6 +224,28 @@ class WoEEncoder(CategoricalMethodsMixin, CategoricalInitMixin, WoE): 2 3 0.287682 3 4 -0.405465 4 5 -0.405465 + + With polars + + >>> import polars as pl + >>> from feature_engine.encoding import WoEEncoder + >>> X = pl.DataFrame(dict(x1 = [1,2,3,4,5], x2 = ["b", "b", "b", "a", "a"])) + >>> y = pl.Series([0,1,1,1,0]) + >>> woe = WoEEncoder() + >>> woe.fit(X, y) + >>> woe.transform(X) + shape: (5, 2) + ┌─────┬───────────┐ + │ x1 ┆ x2 │ + │ --- ┆ --- │ + │ i64 ┆ f64 │ + ╞═════╪═══════════╡ + │ 1 ┆ 0.287682 │ + │ 2 ┆ 0.287682 │ + │ 3 ┆ 0.287682 │ + │ 4 ┆ -0.405465 │ + │ 5 ┆ -0.405465 │ + └─────┴───────────┘ """ def __init__( @@ -218,17 +266,17 @@ def __init__( self.unseen = unseen self.fill_value = fill_value - def fit(self, X: pd.DataFrame, y: pd.Series): + def fit(self, X: IntoDataFrame, y: IntoSeries): """ Learn the WoE. Parameters ---------- - X: pandas dataframe of shape = [n_samples, n_features] + X: dataframe of shape = [n_samples, n_features] The training input samples. Can be the entire dataframe, not just the categorical variables. - y: pandas series. + y: Series. Target, must be binary. """ X, y = self._check_fit_input(X, y) @@ -238,12 +286,55 @@ def fit(self, X: pd.DataFrame, y: pd.Series): encoder_dict_ = {} vars_that_fail = [] - for var in variables_: - try: - _, _, woe = self._calculate_woe(X, y, var, self.fill_value) - encoder_dict_[var] = woe.to_dict() - except ValueError: - vars_that_fail.append(var) + # _calculate_woe() keeps its pandas-native two-groupby implementation + # (it's directly unit-tested for that exact pandas-Series-with- + # category-index return contract); polars and other narwhals + # backends compute the same ratio-then-log logic with a single + # group_by() instead - it derives the negative-class count as the + # complement of the positive-class count per category, so only one + # groupby is needed instead of two (benchmarked competitive with, + # and often faster than, pandas-native at 50k-100k rows). + if nwd.is_pandas_dataframe(X): + for var in variables_: + try: + _, _, woe = self._calculate_woe(X, y, var, self.fill_value) + encoder_dict_[var] = woe.to_dict() + except ValueError: + vars_that_fail.append(var) + else: + nw_X = nw.from_native(X, eager_only=True) + y_nw = nw.from_native(y, series_only=True) + target_name = "__feature_engine_woe_target__" + nw_Xy = nw_X.with_columns(y_nw.alias(target_name)) + + total_pos = y_nw.sum() + total_neg = len(y_nw) - total_pos + + for var in variables_: + grouped = ( + nw_Xy.group_by(var, drop_null_keys=True) + .agg( + nw.col(target_name).sum().alias("__pos_n__"), + nw.len().alias("__n__"), + ) + .sort(var) + ) + categories = grouped.get_column(var).to_list() + pos = (grouped.get_column("__pos_n__") / total_pos).to_numpy() + neg = ( + (grouped.get_column("__n__") - grouped.get_column("__pos_n__")) + / total_neg + ).to_numpy() + + if (pos == 0).any() or (neg == 0).any(): + if self.fill_value is None: + vars_that_fail.append(var) + continue + pos = np.where(pos == 0, self.fill_value, pos) + neg = np.where(neg == 0, self.fill_value, neg) + + woe = np.log(pos / neg) + encoder_dict_[var] = dict(zip(categories, woe)) if len(vars_that_fail) > 0: vars_that_fail_str = ( @@ -263,23 +354,23 @@ def fit(self, X: pd.DataFrame, y: pd.Series): self._get_feature_names_in(X) return self - def transform(self, X: pd.DataFrame) -> pd.DataFrame: + def transform(self, X: IntoDataFrame) -> IntoDataFrame: """Replace categories with the learned parameters. Parameters ---------- - X: pandas dataframe of shape = [n_samples, n_features]. + X: dataframe of shape = [n_samples, n_features]. The dataset to transform. Returns ------- - X_new: pandas dataframe of shape = [n_samples, n_features]. + X_new: dataframe of shape = [n_samples, n_features]. The dataframe containing the categories replaced by numbers. """ - X = self._check_transform_input_and_state(X) + nw_X = self._check_transform_input_and_state(X) _check_contains_na(X, self.variables_) - X = self._encode(X) + X = self._encode(nw_X) return X def _more_tags(self): diff --git a/tests/test_encoding/test_woe/test_woe_encoder.py b/tests/test_encoding/test_woe/test_woe_encoder.py index a38caa6fa..ab05eb289 100644 --- a/tests/test_encoding/test_woe/test_woe_encoder.py +++ b/tests/test_encoding/test_woe/test_woe_encoder.py @@ -1,7 +1,10 @@ import math +import re +import narwhals as nw import numpy as np import pandas as pd +import polars as pl import pytest from sklearn.exceptions import NotFittedError @@ -53,17 +56,55 @@ 0.8472978603872037, ] - -def test_automatically_select_variables(df_enc): +DF_ENC = { + "var_A": ["A"] * 6 + ["B"] * 10 + ["C"] * 4, + "var_B": ["A"] * 10 + ["B"] * 6 + ["C"] * 4, + "target": [1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 1, 1, 0, 0], +} + +DF_ENC_NUMERIC = { + "var_A": [1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 3, 3, 3, 3], + "var_B": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 2, 3, 3, 3, 3], + "target": [1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 1, 1, 0, 0], +} + +DF_ENC_RARE = { + "var_A": ["B"] * 9 + ["A"] * 6 + ["C"] * 4 + ["D"] * 1, + "var_B": ["A"] * 10 + ["B"] * 6 + ["C"] * 4, + "target": [1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 1, 1, 0, 0], +} + +# None (not np.nan) is what both pandas and polars accept as a missing +# value inside a string column literal. +DF_ENC_NA = { + "var_A": [None] + ["B"] * 8 + ["A"] * 6 + ["C"] * 4 + ["D"] * 1, + "var_B": ["A"] * 10 + ["B"] * 6 + ["C"] * 4, + "target": [1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 1, 1, 0, 0], +} + + +def _none_to_nan(values): + # Missing values print as None for polars, NaN for pandas float columns + # - both mean "missing" here, so normalize both sides before comparing. + return [np.nan if v is None else v for v in values] + + +def assert_df_equal(X, expected: dict, abs_tol: float = 1e-5) -> None: + result = nw.from_native(X, eager_only=True).to_dict(as_series=False) + assert list(result.keys()) == list(expected.keys()) + for col, values in expected.items(): + assert _none_to_nan(result[col]) == pytest.approx( + _none_to_nan(values), abs=abs_tol, nan_ok=True + ) + + +@pytest.mark.parametrize("make_df", [pd.DataFrame, pl.DataFrame]) +def test_automatically_select_variables(make_df): + df_enc = make_df(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"] = VAR_A - transf_df["var_B"] = VAR_B - assert encoder.encoder_dict_ == { "var_A": { "A": 0.15415067982725836, @@ -76,19 +117,16 @@ def test_automatically_select_variables(df_enc): "C": 0.8472978603872037, }, } - pd.testing.assert_frame_equal(X, transf_df[["var_A", "var_B"]]) + assert_df_equal(X, {"var_A": VAR_A, "var_B": VAR_B}) -def test_user_passes_variables(df_enc): +@pytest.mark.parametrize("make_df", [pd.DataFrame, pl.DataFrame]) +def test_user_passes_variables(make_df): + df_enc = make_df(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, @@ -101,7 +139,9 @@ def test_user_passes_variables(df_enc): "C": 0.8472978603872037, }, } - pd.testing.assert_frame_equal(X, transf_df) + assert_df_equal( + X, {"var_A": VAR_A, "var_B": VAR_B, "target": DF_ENC["target"]} + ) _targets = [ @@ -111,18 +151,16 @@ def test_user_passes_variables(df_enc): ] +@pytest.mark.parametrize("make_df", [pd.DataFrame, pl.DataFrame]) @pytest.mark.parametrize("target", _targets) -def test_when_target_class_not_0_1(df_enc, target): +def test_when_target_class_not_0_1(make_df, target): + data = dict(DF_ENC) + data["target"] = target + df_enc = make_df(data) 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, @@ -135,10 +173,13 @@ def test_when_target_class_not_0_1(df_enc, target): "C": 0.8472978603872037, }, } - pd.testing.assert_frame_equal(X, transf_df) + assert_df_equal(X, {"var_A": VAR_A, "var_B": VAR_B, "target": target}) -def test_warn_if_transform_df_contains_categories_not_seen_in_fit(df_enc, df_enc_rare): +@pytest.mark.parametrize("make_df", [pd.DataFrame, pl.DataFrame]) +def test_warn_if_transform_df_contains_categories_not_seen_in_fit(make_df): + df_enc = make_df(DF_ENC) + df_enc_rare = make_df(DF_ENC_RARE) # test case 3: when dataset to be transformed contains categories not present # in training dataset msg = "During the encoding, NaN values were introduced in the feature(s) var_A." @@ -156,16 +197,14 @@ def test_warn_if_transform_df_contains_categories_not_seen_in_fit(df_enc, df_enc assert any(r.message.args[0] == msg for r in record) # check for error when rare_labels equals 'raise' - with pytest.raises(ValueError) as record: - encoder = WoEEncoder(unseen="raise") - encoder.fit(df_enc[["var_A", "var_B"]], df_enc["target"]) + encoder = WoEEncoder(unseen="raise") + encoder.fit(df_enc[["var_A", "var_B"]], df_enc["target"]) + with pytest.raises(ValueError, match=re.escape(msg)): encoder.transform(df_enc_rare[["var_A", "var_B"]]) - # check that the error message matches - assert str(record.value) == msg - -def test_error_if_target_not_binary(): +@pytest.mark.parametrize("make_df", [pd.DataFrame, pl.DataFrame]) +def test_error_if_target_not_binary(make_df): # test case 4: the target is not binary encoder = WoEEncoder(variables=None) with pytest.raises(ValueError): @@ -174,107 +213,101 @@ def test_error_if_target_not_binary(): "var_B": ["A"] * 10 + ["B"] * 6 + ["C"] * 4, "target": [1, 1, 2, 2, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 1, 1, 0, 0], } - df = pd.DataFrame(df) + df = make_df(df) encoder.fit(df[["var_A", "var_B"]], df["target"]) -def test_error_if_denominator_probability_is_zero_1_var(): +@pytest.mark.parametrize("make_df", [pd.DataFrame, pl.DataFrame]) +def test_error_if_denominator_probability_is_zero_1_var(make_df): df = { "var_A": ["A"] * 6 + ["B"] * 10 + ["C"] * 4, "var_B": ["A"] * 10 + ["B"] * 6 + ["C"] * 4, "target": [1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 1, 1, 0, 0], } - df = pd.DataFrame(df) + df = make_df(df) encoder = WoEEncoder(variables=None) - with pytest.raises(ValueError) as record: - encoder.fit(df[["var_A", "var_B"]], df["target"]) - msg = ( "During the WoE calculation, some of the categories in the " "following features contained 0 in the denominator or numerator, " "and hence the WoE can't be calculated: var_A." ) - assert str(record.value) == msg + with pytest.raises(ValueError, match=msg): + encoder.fit(df[["var_A", "var_B"]], df["target"]) df = { "var_A": ["A"] * 10 + ["B"] * 6 + ["C"] * 4, "var_B": ["A"] * 6 + ["B"] * 10 + ["C"] * 4, "target": [1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 1, 1, 0, 0], } - df = pd.DataFrame(df) + df = make_df(df) encoder = WoEEncoder(variables=None) - with pytest.raises(ValueError) as record: - encoder.fit(df[["var_A", "var_B"]], df["target"]) - msg = ( "During the WoE calculation, some of the categories in the " "following features contained 0 in the denominator or numerator, " "and hence the WoE can't be calculated: var_B." ) - assert str(record.value) == msg + with pytest.raises(ValueError, match=msg): + encoder.fit(df[["var_A", "var_B"]], df["target"]) -def test_error_if_denominator_probability_is_zero_2_vars(): +@pytest.mark.parametrize("make_df", [pd.DataFrame, pl.DataFrame]) +def test_error_if_denominator_probability_is_zero_2_vars(make_df): df = { "var_A": ["A"] * 6 + ["B"] * 10 + ["C"] * 4, "var_B": ["A"] * 10 + ["B"] * 6 + ["C"] * 4, "var_C": ["A"] * 6 + ["B"] * 10 + ["C"] * 4, "target": [1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 1, 1, 0, 0], } - df = pd.DataFrame(df) + df = make_df(df) encoder = WoEEncoder(variables=None) - with pytest.raises(ValueError) as record: - encoder.fit(df, df["target"]) - msg = ( "During the WoE calculation, some of the categories in the " "following features contained 0 in the denominator or numerator, " "and hence the WoE can't be calculated: var_A, var_C." ) - assert str(record.value) == msg + with pytest.raises(ValueError, match=msg): + encoder.fit(df, df["target"]) -def test_error_if_numerator_probability_is_zero(): +@pytest.mark.parametrize("make_df", [pd.DataFrame, pl.DataFrame]) +def test_error_if_numerator_probability_is_zero(make_df): df = { "var_A": ["A"] * 6 + ["B"] * 10 + ["C"] * 4, "var_B": ["A"] * 10 + ["B"] * 6 + ["C"] * 4, "var_C": ["A"] * 6 + ["B"] * 10 + ["C"] * 4, "target": [0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 0, 0, 0, 0, 1, 1, 0, 0], } - df = pd.DataFrame(df) + df = make_df(df) encoder = WoEEncoder(variables=None) - with pytest.raises(ValueError) as record: - encoder.fit(df, df["target"]) - msg = ( "During the WoE calculation, some of the categories in the " "following features contained 0 in the denominator or numerator, " "and hence the WoE can't be calculated: var_A, var_C." ) - assert str(record.value) == msg - - with pytest.raises(ValueError) as record: - encoder.fit(df[["var_A", "var_B"]], df["target"]) + with pytest.raises(ValueError, match=msg): + encoder.fit(df, df["target"]) msg = ( "During the WoE calculation, some of the categories in the " "following features contained 0 in the denominator or numerator, " "and hence the WoE can't be calculated: var_A." ) - assert str(record.value) == msg + with pytest.raises(ValueError, match=msg): + encoder.fit(df[["var_A", "var_B"]], df["target"]) -def test_fill_value(): +@pytest.mark.parametrize("make_df", [pd.DataFrame, pl.DataFrame]) +def test_fill_value(make_df): df = { "var_A": ["A"] * 9 + ["B"] * 6 + ["C"] * 3 + ["D"] * 2, "var_B": ["A"] * 10 + ["B"] * 6 + ["C"] * 4, "target": [1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 0, 1, 1, 1, 0, 0], } - df = pd.DataFrame(df) + df = make_df(df) encoder = WoEEncoder(variables=None, fill_value=1) encoder.fit(df, df["target"]) woe_exp_a = { @@ -320,43 +353,42 @@ def test_assigns_fill_value_at_init(fill_value): assert encoder.fill_value == fill_value -def test_error_if_contains_na_in_fit(df_enc_na): +@pytest.mark.parametrize("make_df", [pd.DataFrame, pl.DataFrame]) +def test_error_if_contains_na_in_fit(make_df): # test case 9: when dataset contains na, fit method + df_enc_na = make_df(DF_ENC_NA) encoder = WoEEncoder(variables=None) - with pytest.raises(ValueError) as record: - encoder.fit(df_enc_na[["var_A", "var_B"]], df_enc_na["target"]) - msg = ( "Some of the variables in the dataset contain NaN. Check and " "remove those before using this transformer." ) - assert str(record.value) == msg + with pytest.raises(ValueError, match=msg): + encoder.fit(df_enc_na[["var_A", "var_B"]], df_enc_na["target"]) -def test_error_if_df_contains_na_in_transform(df_enc, df_enc_na): +@pytest.mark.parametrize("make_df", [pd.DataFrame, pl.DataFrame]) +def test_error_if_df_contains_na_in_transform(make_df): # test case 10: when dataset contains na, transform method} + df_enc = make_df(DF_ENC) + df_enc_na = make_df(DF_ENC_NA) encoder = WoEEncoder(variables=None) encoder.fit(df_enc[["var_A", "var_B"]], df_enc["target"]) - with pytest.raises(ValueError) as record: - encoder.transform(df_enc_na[["var_A", "var_B"]]) msg = ( "Some of the variables in the dataset contain NaN. Check and " "remove those before using this transformer." ) - assert str(record.value) == msg + with pytest.raises(ValueError, match=msg): + encoder.transform(df_enc_na[["var_A", "var_B"]]) -def test_on_numerical_variables(df_enc_numeric): +@pytest.mark.parametrize("make_df", [pd.DataFrame, pl.DataFrame]) +def test_on_numerical_variables(make_df): # ignore_format=True + df_enc_numeric = make_df(DF_ENC_NUMERIC) encoder = WoEEncoder(variables=None, ignore_format=True) encoder.fit(df_enc_numeric[["var_A", "var_B"]], df_enc_numeric["target"]) X = encoder.transform(df_enc_numeric[["var_A", "var_B"]]) - # transformed dataframe - transf_df = df_enc_numeric.copy() - transf_df["var_A"] = VAR_A - transf_df["var_B"] = VAR_B - # init params assert encoder.variables is None # fit params @@ -375,16 +407,17 @@ def test_on_numerical_variables(df_enc_numeric): } assert encoder.n_features_in_ == 2 # transform params - pd.testing.assert_frame_equal(X, transf_df[["var_A", "var_B"]]) + assert_df_equal(X, {"var_A": VAR_A, "var_B": VAR_B}) -def test_variables_cast_as_category(df_enc_category_dtypes): - df = df_enc_category_dtypes.copy() +def test_variables_cast_as_category(): + # pandas Categorical dtype has no direct polars equivalent. + df = pd.DataFrame(DF_ENC) + df[["var_A", "var_B"]] = df[["var_A", "var_B"]].astype("category") encoder = WoEEncoder(variables=None) encoder.fit(df[["var_A", "var_B"]], df["target"]) X = encoder.transform(df[["var_A", "var_B"]]) - # transformed dataframe transf_df = df.copy() transf_df["var_A"] = VAR_A transf_df["var_B"] = VAR_B @@ -401,19 +434,20 @@ def test_error_if_rare_labels_not_permitted_value(errors): WoEEncoder(unseen=errors) -def test_inverse_transform_raises_non_fitted_error(): - df1 = pd.DataFrame({"words": ["dog", "dog", "cat", "cat", "cat", "bird"]}) +@pytest.mark.parametrize("make_df", [pd.DataFrame, pl.DataFrame]) +def test_inverse_transform_raises_non_fitted_error(make_df): + df1 = make_df({"words": ["dog", "dog", "cat", "cat", "cat", "bird"]}) enc = WoEEncoder() # Test when fit is not called prior to transform. with pytest.raises(NotFittedError): enc.inverse_transform(df1) - df1.loc[len(df1) - 1] = np.nan + df1_na = make_df({"words": ["dog", "dog", "cat", "cat", "cat", None]}) with pytest.raises(ValueError): - enc.fit(df1, pd.Series([0, 1, 0, 1, 1, 0])) + enc.fit(df1_na, make_df({"target": [0, 1, 0, 1, 1, 0]})["target"]) # Test when fit is not called prior to transform. with pytest.raises(NotFittedError): - enc.inverse_transform(df1) + enc.inverse_transform(df1_na)