From fabde017173b2ca447f7e655df74be168b717c89 Mon Sep 17 00:00:00 2001 From: Juss Patel <6782.stkabirdio@gmail.com> Date: Thu, 1 Oct 2026 16:45:55 +0530 Subject: [PATCH 1/3] Introduced OOFMeanEncoder --- docs/api_doc/encoding/OOFMeanEncoder.rst | 5 + docs/api_doc/encoding/index.rst | 2 + feature_engine/encoding/__init__.py | 2 + feature_engine/encoding/oof_mean_encoding.py | 377 ++++++++++++++++++ .../test_check_estimator_encoders.py | 3 + tests/test_encoding/test_oof_mean_encoder.py | 317 +++++++++++++++ 6 files changed, 706 insertions(+) create mode 100644 docs/api_doc/encoding/OOFMeanEncoder.rst create mode 100644 feature_engine/encoding/oof_mean_encoding.py create mode 100644 tests/test_encoding/test_oof_mean_encoder.py diff --git a/docs/api_doc/encoding/OOFMeanEncoder.rst b/docs/api_doc/encoding/OOFMeanEncoder.rst new file mode 100644 index 000000000..743db96da --- /dev/null +++ b/docs/api_doc/encoding/OOFMeanEncoder.rst @@ -0,0 +1,5 @@ +OOFMeanEncoder +============== + +.. autoclass:: feature_engine.encoding.OOFMeanEncoder + :members: diff --git a/docs/api_doc/encoding/index.rst b/docs/api_doc/encoding/index.rst index 6729b2513..dc63bf379 100644 --- a/docs/api_doc/encoding/index.rst +++ b/docs/api_doc/encoding/index.rst @@ -15,6 +15,7 @@ estimated or arbitrary numbers. :class:`OrdinalEncoder()` √ √ √ Replaces categories with an integer :class:`CountEncoder()` √ √ √ Replaces categories with their count or frequency :class:`MeanEncoder()` √ √ x Replaces categories with the target mean value +:class:`OOFMeanEncoder()` √ √ x Out-of-fold target mean encoding :class:`WoEEncoder()` x √ x Replaces categories with the weight of the evidence :class:`DecisionTreeEncoder()` √ √ √ Replaces categories with the predictions of a decision tree :class:`RareLabelEncoder()` √ √ √ Groups infrequent categories into a single one @@ -33,6 +34,7 @@ input. CountEncoder OrdinalEncoder MeanEncoder + OOFMeanEncoder WoEEncoder DecisionTreeEncoder RareLabelEncoder diff --git a/feature_engine/encoding/__init__.py b/feature_engine/encoding/__init__.py index a16e4fd51..61ef7aed9 100644 --- a/feature_engine/encoding/__init__.py +++ b/feature_engine/encoding/__init__.py @@ -5,6 +5,7 @@ from .count_frequency import CountEncoder, CountFrequencyEncoder from .decision_tree import DecisionTreeEncoder from .mean_encoding import MeanEncoder +from .oof_mean_encoding import OOFMeanEncoder from .one_hot import OneHotEncoder from .ordinal import OrdinalEncoder from .rare_label import RareLabelEncoder @@ -16,6 +17,7 @@ "CountFrequencyEncoder", "DecisionTreeEncoder", "MeanEncoder", + "OOFMeanEncoder", "OneHotEncoder", "OrdinalEncoder", "RareLabelEncoder", diff --git a/feature_engine/encoding/oof_mean_encoding.py b/feature_engine/encoding/oof_mean_encoding.py new file mode 100644 index 000000000..d3baa75ae --- /dev/null +++ b/feature_engine/encoding/oof_mean_encoding.py @@ -0,0 +1,377 @@ +# Authors: Soledad Galli +# License: BSD 3 clause +from collections.abc import Iterable +from typing import List, Optional, Union + +import numpy as np +import pandas as pd +from sklearn.model_selection import KFold, StratifiedKFold, check_cv +from sklearn.utils.multiclass import check_classification_targets + +from feature_engine._check_init_parameters.check_init_input_params import ( + _check_return_empty_is_bool, +) +from feature_engine._docstrings.fit_attributes import ( + _feature_names_in_docstring, + _n_features_in_docstring, + _variables_attribute_docstring, +) +from feature_engine._docstrings.init_parameters.all_transformers import ( + _missing_values_docstring, + _return_empty_docstring, + _variables_categorical_docstring, +) +from feature_engine._docstrings.init_parameters.encoders import ( + _ignore_format_docstring, + _unseen_docstring, +) +from feature_engine._docstrings.methods import ( + _fit_transform_docstring, + _inverse_transform_docstring, + _transform_encoders_docstring, +) +from feature_engine._docstrings.substitute import Substitution +from feature_engine.dataframe_checks import check_X_y +from feature_engine.encoding._helper_functions import check_parameter_unseen +from feature_engine.encoding.base_encoder import ( + CategoricalInitMixinNA, + CategoricalMethodsMixin, +) + +_unseen_docstring = ( + _unseen_docstring + + """ If `'encode'`, unseen categories will be encoded with the prior.""" +) + + +@Substitution( + missing_values=_missing_values_docstring, + ignore_format=_ignore_format_docstring, + variables=_variables_categorical_docstring, + return_empty=_return_empty_docstring, + unseen=_unseen_docstring, + variables_=_variables_attribute_docstring, + feature_names_in_=_feature_names_in_docstring, + n_features_in_=_n_features_in_docstring, + fit_transform=_fit_transform_docstring, + transform=_transform_encoders_docstring, + inverse_transform=_inverse_transform_docstring, +) +class OOFMeanEncoder(CategoricalMethodsMixin, CategoricalInitMixinNA): + """ + The OOFMeanEncoder() (Out-of-Fold Mean Encoder) replaces categories with the mean + target value per category using an out-of-fold cross-validation scheme during + ``fit_transform()`` to prevent target leakage and overfitting. + + When target encoding is computed on the entire training set and applied directly to + the same rows, each row's own target value contributes to its encoded value. For + rare categories or small datasets, this causes severe target leakage where the + model memorizes training labels rather than generalizing. + + The ``OOFMeanEncoder()`` solves this problem during training by splitting the + dataset into cross-validation folds (via ``fit_transform()``). For each fold, + the target encoding mappings are learned strictly using the remaining folds, and + applied to the held-out fold. Consequently, no row's target value is ever used in + its own encoding. + + When ``fit()`` is called, or when transforming new unseen/test data with + ``transform()``, the mappings learned across the entire dataset are used, matching + the behavior of ``MeanEncoder()``. + + Parameters + ---------- + {variables} + + cv: int, cross-validation generator or an iterable, default=5 + Determines the cross-validation splitting strategy for out-of-fold encoding. + Possible inputs for cv are: + + - None, to use default 5-fold cross-validation + - int, to specify the number of folds in a KFold or StratifiedKFold + - CV splitter (e.g., KFold, StratifiedKFold, TimeSeriesSplit) + - An iterable yielding (train, test) splits as arrays of indices + + smoothing: int, float, str, default=0.0 + Smoothing factor. Should be >= 0. If 0 then no smoothing is applied, and the + mean target value per category is returned without modification. If 'auto' then + wi is calculated as described in MeanEncoder and the category is encoded as the + blended values of the prior and the posterior. If int or float, then wi is + calculated as ni / (ni + smoothing). Higher values lead to stronger smoothing + (higher weight of prior). + + {unseen} + + {missing_values} + + {ignore_format} + + {return_empty} + + shuffle: bool, default=False + Whether to shuffle the data before splitting into folds when ``cv`` is an + integer. + + random_state: int, RandomState instance or None, default=None + Controls the randomness of the fold generation when ``shuffle=True`` and + ``cv`` is an integer. Pass an int for reproducible output. + + stratify: bool, default=False + Whether to use stratified cross-validation (StratifiedKFold) when ``cv`` is an + integer. Requires discrete / classification targets. + + Attributes + ---------- + encoder_dict_: + Dictionary with the target mean value per category per variable learned on the + full training set. + + y_prior_: + Mean of the target across the entire training set. + + {variables_} + + {feature_names_in_} + + {n_features_in_} + + Methods + ------- + fit: + Learn the target mean value per category, per variable from the entire dataset. + + {fit_transform} + + {inverse_transform} + + {transform} + + See Also + -------- + feature_engine.encoding.MeanEncoder + feature_engine.encoding.RareLabelEncoder + """ + + def __init__( + self, + variables: Union[None, int, str, List[Union[str, int]]] = None, + cv: Union[int, None, Iterable] = 5, + smoothing: Union[int, float, str] = 0.0, + unseen: str = "ignore", + missing_values: str = "raise", + ignore_format: bool = False, + return_empty: bool = False, + shuffle: bool = False, + random_state: Optional[int] = None, + stratify: bool = False, + ) -> None: + _check_return_empty_is_bool(return_empty) + + super().__init__(variables, missing_values, ignore_format) + self.return_empty = return_empty + + if ( + not isinstance(smoothing, (str, float, int)) + or (isinstance(smoothing, str) and smoothing != "auto") + or (isinstance(smoothing, (float, int)) and smoothing < 0) + ): + raise ValueError( + f"smoothing must be greater than 0 or 'auto'. " + f"Got {smoothing} instead." + ) + self.smoothing = smoothing + + check_parameter_unseen(unseen, ["ignore", "raise", "encode"]) + self.unseen = unseen + + if cv is not None and isinstance(cv, int) and cv < 2: + raise ValueError( + f"cv must be an integer greater than 1 or a CV splitter. Got {cv} instead." + ) + self.cv = cv + + if not isinstance(shuffle, bool): + raise ValueError( + f"shuffle takes only booleans True and False. Got {shuffle} instead." + ) + self.shuffle = shuffle + self.random_state = random_state + + if not isinstance(stratify, bool): + raise ValueError( + f"stratify takes only booleans True and False. Got {stratify} instead." + ) + self.stratify = stratify + + def _calculate_target_means( + self, + X: pd.DataFrame, + y: pd.Series, + variables: List[Union[str, int]], + y_prior: float, + ) -> dict: + encoder_dict = {} + if self.smoothing == "auto": + y_var = y.var(ddof=0) + for var in variables: + if self.smoothing == "auto": + damping = y.groupby(X[var]).var(ddof=0) / y_var + else: + damping = self.smoothing + counts = X[var].value_counts() + counts.index = counts.index.infer_objects() + _lambda = counts / (counts + damping) + encoder_dict[var] = ( + _lambda * y.groupby(X[var], observed=False).mean() + + (1.0 - _lambda) * y_prior + ).to_dict() + return encoder_dict + + def _get_cv_splitter(self, X: pd.DataFrame, y: pd.Series): + if self.cv is None or isinstance(self.cv, int): + n_splits = 5 if self.cv is None else self.cv + if n_splits < 2: + raise ValueError( + f"cv must be an integer greater than 1. Got {self.cv} instead." + ) + if self.stratify: + check_classification_targets(y) + return StratifiedKFold( + n_splits=n_splits, + shuffle=self.shuffle, + random_state=self.random_state if self.shuffle else None, + ) + else: + return KFold( + n_splits=n_splits, + shuffle=self.shuffle, + random_state=self.random_state if self.shuffle else None, + ) + else: + return check_cv(self.cv, y=y, classifier=self.stratify) + + def fit(self, X: pd.DataFrame, y: pd.Series): + """ + Learn the mean value of the target for each category of the variable + across the entire training set. + + Parameters + ---------- + X: pandas dataframe of shape = [n_samples, n_features] + The training input samples. + + y: pandas series + The target. + """ + X, y = check_X_y(X, y) + variables_ = self._check_or_select_variables(X) + self._check_na(X, variables_) + + y_prior = float(y.mean()) + self.y_prior_ = y_prior + + if self.unseen == "encode": + self._unseen = y_prior + + self.encoder_dict_ = self._calculate_target_means( + X, y, variables_, y_prior + ) + + self.variables_ = variables_ + self._get_feature_names_in(X) + return self + + def fit_transform(self, X: pd.DataFrame, y: pd.Series) -> pd.DataFrame: + """ + Fit to data, then transform it with out-of-fold target encoding. + + Fits the transformer using the full training set (populating `encoder_dict_` + and attributes for subsequent `transform` calls), then transforms `X` using + out-of-fold target encoding where each row is encoded using only the + other folds. + + Parameters + ---------- + X: pandas dataframe of shape = [n_samples, n_features] + The training input samples. + + y: pandas series + The target. + + Returns + ------- + X_tr: pandas dataframe of shape = [n_samples, n_features] + The out-of-fold encoded dataframe. + """ + X, y = check_X_y(X, y) + + self.fit(X, y) + + if not self.variables_: + return X + + splitter = self._get_cv_splitter(X, y) + + X_tr = X.copy() + n_samples = len(X) + + encoded_cols = { + var: np.full(n_samples, np.nan, dtype=float) for var in self.variables_ + } + + for train_idx, val_idx in splitter.split(X, y): + X_train_f = X.iloc[train_idx] + y_train_f = y.iloc[train_idx] + X_val_f = X.iloc[val_idx] + + y_prior_f = float(y_train_f.mean()) + + fold_mappings = self._calculate_target_means( + X_train_f, y_train_f, self.variables_, y_prior_f + ) + + for var in self.variables_: + mapped_val = X_val_f[var].map(fold_mappings[var]) + if self.unseen == "encode": + mapped_val = mapped_val.fillna(y_prior_f) + encoded_cols[var][val_idx] = mapped_val.to_numpy(dtype=float) + + for var in self.variables_: + X_tr[var] = encoded_cols[var] + + if self.unseen != "encode": + self._check_nan_values_after_transformation(X_tr) + + return X_tr + + def inverse_transform(self, X: pd.DataFrame) -> pd.DataFrame: + """Convert the encoded variable back to the original values. + + Note that if unseen was set to 'encode', then this method is not implemented. + + Parameters + ---------- + X: pandas dataframe of shape = [n_samples, n_features]. + The transformed dataframe. + + Returns + ------- + X_tr: pandas dataframe of shape = [n_samples, n_features]. + The un-transformed dataframe, with the categorical variables containing the + original values. + """ + if self.unseen == "encode": + raise NotImplementedError( + "inverse_transform is not implemented for this transformer when " + "`unseen='encode'`." + ) + else: + return super().inverse_transform(X) + + def _more_tags(self): + tags_dict = super()._more_tags() + tags_dict["requires_y"] = True + return tags_dict + + def __sklearn_tags__(self): + tags = super().__sklearn_tags__() + return tags diff --git a/tests/test_encoding/test_check_estimator_encoders.py b/tests/test_encoding/test_check_estimator_encoders.py index fbe9ce00f..3083601d4 100644 --- a/tests/test_encoding/test_check_estimator_encoders.py +++ b/tests/test_encoding/test_check_estimator_encoders.py @@ -13,6 +13,7 @@ CountFrequencyEncoder, DecisionTreeEncoder, MeanEncoder, + OOFMeanEncoder, OneHotEncoder, OrdinalEncoder, RareLabelEncoder, @@ -34,6 +35,7 @@ # breaks with sklearn 1.4.1 - check and fix? # DecisionTreeEncoder(regression=False, ignore_format=True), MeanEncoder(ignore_format=True), + OOFMeanEncoder(ignore_format=True), OneHotEncoder(ignore_format=True), OrdinalEncoder(ignore_format=True), RareLabelEncoder( @@ -71,6 +73,7 @@ def test_check_estimator_from_sklearn(estimator): CountFrequencyEncoder(), DecisionTreeEncoder(regression=False), MeanEncoder(), + OOFMeanEncoder(), OneHotEncoder(), OrdinalEncoder(), RareLabelEncoder(), diff --git a/tests/test_encoding/test_oof_mean_encoder.py b/tests/test_encoding/test_oof_mean_encoder.py new file mode 100644 index 000000000..c19fd21d3 --- /dev/null +++ b/tests/test_encoding/test_oof_mean_encoder.py @@ -0,0 +1,317 @@ +import numpy as np +import pandas as pd +import pytest +from numpy import nan +from sklearn.exceptions import NotFittedError +from sklearn.model_selection import KFold, StratifiedKFold +from sklearn.pipeline import Pipeline + +from feature_engine.encoding import OOFMeanEncoder + + +# test init params +@pytest.mark.parametrize( + "params", [("raise", True, "auto", 3, True, 1, False), ("ignore", False, 1, 5, False, None, True)] +) +def test_init_param_assignment(params): + enc = OOFMeanEncoder( + missing_values=params[0], + ignore_format=params[1], + smoothing=params[2], + cv=params[3], + shuffle=params[4], + random_state=params[5], + stratify=params[6], + ) + assert enc.missing_values == params[0] + assert enc.ignore_format == params[1] + assert enc.smoothing == params[2] + assert enc.cv == params[3] + assert enc.shuffle == params[4] + assert enc.random_state == params[5] + assert enc.stratify == params[6] + + +@pytest.mark.parametrize( + "errors", ["invalid", False, 1, ("raise", "ignore"), ["ignore"]] +) +def test_error_if_unseen_gets_not_permitted_value(errors): + with pytest.raises(ValueError): + OOFMeanEncoder(unseen=errors) + + +@pytest.mark.parametrize("smoothing", ["hello", ["auto"], -1, -0.5]) +def test_raises_error_when_not_allowed_smoothing_param_in_init(smoothing): + with pytest.raises(ValueError): + OOFMeanEncoder(smoothing=smoothing) + + +@pytest.mark.parametrize("cv", [0, 1, -2]) +def test_raises_error_when_cv_less_than_2(cv): + with pytest.raises(ValueError): + OOFMeanEncoder(cv=cv) + + +@pytest.mark.parametrize("shuffle", ["true", 1, None, []]) +def test_raises_error_when_shuffle_not_bool(shuffle): + with pytest.raises(ValueError): + OOFMeanEncoder(shuffle=shuffle) + + +@pytest.mark.parametrize("stratify", ["true", 1, None, []]) +def test_raises_error_when_stratify_not_bool(stratify): + with pytest.raises(ValueError): + OOFMeanEncoder(stratify=stratify) + + +# fit and transform +def test_fit_and_transform_single_variable(df_enc): + encoder = OOFMeanEncoder(variables=["var_A"]) + encoder.fit(df_enc[["var_A", "var_B"]], df_enc["target"]) + X = encoder.transform(df_enc[["var_A", "var_B"]]) + + # expected output + transf_df = df_enc.copy() + transf_df["var_A"] = [ + 0.3333333333333333, + 0.3333333333333333, + 0.3333333333333333, + 0.3333333333333333, + 0.3333333333333333, + 0.3333333333333333, + 0.2, + 0.2, + 0.2, + 0.2, + 0.2, + 0.2, + 0.2, + 0.2, + 0.2, + 0.2, + 0.5, + 0.5, + 0.5, + 0.5, + ] + + assert encoder.variables == ["var_A"] + assert encoder.variables_ == ["var_A"] + assert encoder.encoder_dict_ == { + "var_A": {"A": 0.3333333333333333, "B": 0.2, "C": 0.5} + } + assert encoder.n_features_in_ == 2 + pd.testing.assert_frame_equal(X, transf_df[["var_A", "var_B"]]) + + +def test_automatically_find_variables(df_enc): + encoder = OOFMeanEncoder(variables=None) + encoder.fit(df_enc[["var_A", "var_B"]], df_enc["target"]) + assert encoder.variables_ == ["var_A", "var_B"] + assert "var_A" in encoder.encoder_dict_ + assert "var_B" in encoder.encoder_dict_ + + +def test_oof_encoding_prevents_leakage(): + # Construct a dataset where category 'A' has target 1 in fold 1 and 0 in fold 2. + # Out of fold encoding should assign 0 to fold 1 and 1 to fold 2. + df = pd.DataFrame( + { + "cat": ["A", "A", "A", "A"], + "num": [10, 20, 30, 40], + } + ) + y = pd.Series([1, 1, 0, 0]) + + encoder = OOFMeanEncoder(variables=["cat"], cv=2, shuffle=False) + X_oof = encoder.fit_transform(df, y) + + # Fold 1 (rows 0, 1): trained on fold 2 (target [0, 0]), so mapped to 0.0 + # Fold 2 (rows 2, 3): trained on fold 1 (target [1, 1]), so mapped to 1.0 + expected_oof = [0.0, 0.0, 1.0, 1.0] + assert np.allclose(X_oof["cat"].tolist(), expected_oof) + + # Subsequent transform on new data uses full dataset mapping (mean = 0.5) + X_test = encoder.transform(df) + assert np.allclose(X_test["cat"].tolist(), [0.5, 0.5, 0.5, 0.5]) + + +def test_cv_as_custom_splitter(df_enc): + cv = KFold(n_splits=4, shuffle=False) + encoder = OOFMeanEncoder(cv=cv, unseen="encode") + X_tr = encoder.fit_transform(df_enc[["var_A", "var_B"]], df_enc["target"]) + + assert encoder.n_features_in_ == 2 + assert len(X_tr) == len(df_enc) + assert not X_tr.isnull().any().any() + + +def test_cv_stratified(df_enc): + cv = StratifiedKFold(n_splits=2, shuffle=False) + encoder = OOFMeanEncoder(cv=cv, stratify=True, unseen="encode") + X_tr = encoder.fit_transform(df_enc[["var_A", "var_B"]], df_enc["target"]) + + assert len(X_tr) == len(df_enc) + assert not X_tr.isnull().any().any() + + +def test_stratify_raises_error_on_continuous_target(): + df = pd.DataFrame({"cat": ["A", "B", "A", "B", "A", "B"]}) + y = pd.Series([1.23, 4.56, 7.89, 0.12, 3.45, 6.78]) + + encoder = OOFMeanEncoder(cv=2, stratify=True) + with pytest.raises(ValueError): + encoder.fit_transform(df, y) + + +def test_smoothing_fixed(df_enc): + encoder = OOFMeanEncoder( + variables=["var_A"], smoothing=10, cv=2, shuffle=False, unseen="encode" + ) + X_oof = encoder.fit_transform(df_enc[["var_A", "var_B"]], df_enc["target"]) + assert len(X_oof) == len(df_enc) + assert not X_oof["var_A"].isnull().any() + + +def test_smoothing_auto(df_enc): + encoder = OOFMeanEncoder( + variables=["var_A"], smoothing="auto", cv=2, shuffle=False, unseen="encode" + ) + X_oof = encoder.fit_transform(df_enc[["var_A", "var_B"]], df_enc["target"]) + assert len(X_oof) == len(df_enc) + assert not X_oof["var_A"].isnull().any() + + +def test_unseen_encode(): + # If a category is only in validation fold, unseen='encode' should fill with prior + df = pd.DataFrame( + { + "cat": ["A", "A", "B", "C"], + } + ) + y = pd.Series([1, 0, 1, 0]) + + encoder = OOFMeanEncoder(variables=["cat"], cv=2, shuffle=False, unseen="encode") + X_oof = encoder.fit_transform(df, y) + assert not X_oof["cat"].isnull().any() + + # Inference with unseen category + df_test = pd.DataFrame({"cat": ["D"]}) + X_test = encoder.transform(df_test) + assert np.allclose(X_test["cat"].iloc[0], encoder.y_prior_) + + +def test_unseen_raise(): + df_train = pd.DataFrame({"cat": ["A", "B", "A", "B"]}) + y_train = pd.Series([1, 0, 1, 0]) + encoder = OOFMeanEncoder(unseen="raise") + encoder.fit(df_train, y_train) + + df_test = pd.DataFrame({"cat": ["C"]}) + with pytest.raises(ValueError) as record: + encoder.transform(df_test) + assert "During the encoding, NaN values were introduced" in str(record.value) + + +def test_missing_values_raise_and_ignore(): + df = pd.DataFrame({"cat": ["A", "B", nan, "B"], "target": [1, 0, 1, 0]}) + + encoder_raise = OOFMeanEncoder(missing_values="raise") + with pytest.raises(ValueError): + encoder_raise.fit(df[["cat"]], df["target"]) + + with pytest.raises(ValueError): + encoder_raise.fit_transform(df[["cat"]], df["target"]) + + encoder_ignore = OOFMeanEncoder(missing_values="ignore", cv=2, shuffle=False) + encoder_ignore.fit(df[["cat"]], df["target"]) + X_tr = encoder_ignore.transform(df[["cat"]]) + assert np.isnan(X_tr.loc[2, "cat"]) + + +def test_ignore_format_numerical_variables(df_enc_numeric): + encoder = OOFMeanEncoder( + ignore_format=True, cv=2, shuffle=False, unseen="encode" + ) + X_tr = encoder.fit_transform( + df_enc_numeric[["var_A", "var_B"]], df_enc_numeric["target"] + ) + assert encoder.variables_ == ["var_A", "var_B"] + assert not X_tr.isnull().any().any() + + +def test_unseen_ignore_warns_when_fold_has_unseen_category(): + # Category 'C' only exists in fold 2. With unseen='ignore', fold 2 will have NaNs for 'A', + # and fold 1 will have NaNs for 'C', which triggers a warning. + df = pd.DataFrame( + { + "cat": ["A", "A", "B", "B", "C", "C"], + } + ) + y = pd.Series([1, 0, 1, 0, 1, 0]) + + encoder = OOFMeanEncoder(cv=2, shuffle=False, unseen="ignore") + with pytest.warns(UserWarning, match="During the encoding, NaN values were introduced"): + X_oof = encoder.fit_transform(df, y) + assert X_oof["cat"].isnull().any() + + +def test_return_empty(): + df = pd.DataFrame({"num1": [1, 2, 3], "num2": [4, 5, 6]}) + y = pd.Series([1, 0, 1]) + encoder = OOFMeanEncoder(return_empty=True) + encoder.fit(df, y) + assert encoder.variables_ == [] + pd.testing.assert_frame_equal(encoder.transform(df), df) + pd.testing.assert_frame_equal(encoder.fit_transform(df, y), df) + + +def test_preserves_non_standard_dataframe_index(): + df = pd.DataFrame( + {"cat": ["A", "B", "A", "B", "A", "B"]}, + index=[105, 42, 999, 12, 50, 77], + ) + y = pd.Series([1, 0, 1, 0, 1, 0], index=[105, 42, 999, 12, 50, 77]) + + encoder = OOFMeanEncoder(cv=3, shuffle=False) + X_oof = encoder.fit_transform(df, y) + + pd.testing.assert_index_equal(X_oof.index, df.index) + X_test = encoder.transform(df) + pd.testing.assert_index_equal(X_test.index, df.index) + + +def test_inverse_transform(): + df = pd.DataFrame({"words": ["dog", "dog", "cat", "cat", "cat", "bird"]}) + y = pd.Series([1, 0, 1, 0, 1, 0]) + enc = OOFMeanEncoder() + enc.fit(df, y) + dft = enc.transform(df) + pd.testing.assert_frame_equal(enc.inverse_transform(dft), df) + + # raises NotImplementedError when unseen='encode' + enc_encode = OOFMeanEncoder(unseen="encode") + enc_encode.fit(df, y) + dft_encode = enc_encode.transform(df) + with pytest.raises(NotImplementedError): + enc_encode.inverse_transform(dft_encode) + + +def test_inverse_transform_raises_non_fitted_error(): + df = pd.DataFrame({"words": ["dog", "cat"]}) + enc = OOFMeanEncoder() + with pytest.raises(NotFittedError): + enc.inverse_transform(df) + + +def test_pipeline_compatibility(df_enc): + pipe = Pipeline( + [ + ("oof", OOFMeanEncoder(variables=["var_A"], cv=2, shuffle=False)), + ] + ) + X_tr = pipe.fit_transform(df_enc[["var_A", "var_B"]], df_enc["target"]) + assert len(X_tr) == len(df_enc) + + X_test = pipe.transform(df_enc[["var_A", "var_B"]]) + assert len(X_test) == len(df_enc) From a76b03c7864d081daa0c9295623fef8b7564cf37 Mon Sep 17 00:00:00 2001 From: Juss Patel <6782.stkabirdio@gmail.com> Date: Fri, 2 Oct 2026 17:44:41 +0530 Subject: [PATCH 2/3] patching some circle ci failed tests --- feature_engine/encoding/oof_mean_encoding.py | 3 ++- tests/test_encoding/test_oof_mean_encoder.py | 13 +++++++++---- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/feature_engine/encoding/oof_mean_encoding.py b/feature_engine/encoding/oof_mean_encoding.py index d3baa75ae..c5ea73a59 100644 --- a/feature_engine/encoding/oof_mean_encoding.py +++ b/feature_engine/encoding/oof_mean_encoding.py @@ -185,7 +185,8 @@ def __init__( if cv is not None and isinstance(cv, int) and cv < 2: raise ValueError( - f"cv must be an integer greater than 1 or a CV splitter. Got {cv} instead." + "cv must be an integer greater than 1 or a CV splitter. " + f"Got {cv} instead." ) self.cv = cv diff --git a/tests/test_encoding/test_oof_mean_encoder.py b/tests/test_encoding/test_oof_mean_encoder.py index c19fd21d3..2e4105a9e 100644 --- a/tests/test_encoding/test_oof_mean_encoder.py +++ b/tests/test_encoding/test_oof_mean_encoder.py @@ -11,7 +11,11 @@ # test init params @pytest.mark.parametrize( - "params", [("raise", True, "auto", 3, True, 1, False), ("ignore", False, 1, 5, False, None, True)] + "params", + [ + ("raise", True, "auto", 3, True, 1, False), + ("ignore", False, 1, 5, False, None, True), + ], ) def test_init_param_assignment(params): enc = OOFMeanEncoder( @@ -241,8 +245,8 @@ def test_ignore_format_numerical_variables(df_enc_numeric): def test_unseen_ignore_warns_when_fold_has_unseen_category(): - # Category 'C' only exists in fold 2. With unseen='ignore', fold 2 will have NaNs for 'A', - # and fold 1 will have NaNs for 'C', which triggers a warning. + # Category 'C' only exists in fold 2. With unseen='ignore', fold 2 will have + # NaNs for 'A', and fold 1 will have NaNs for 'C', which triggers a warning. df = pd.DataFrame( { "cat": ["A", "A", "B", "B", "C", "C"], @@ -251,7 +255,8 @@ def test_unseen_ignore_warns_when_fold_has_unseen_category(): y = pd.Series([1, 0, 1, 0, 1, 0]) encoder = OOFMeanEncoder(cv=2, shuffle=False, unseen="ignore") - with pytest.warns(UserWarning, match="During the encoding, NaN values were introduced"): + msg = "During the encoding, NaN values were introduced" + with pytest.warns(UserWarning, match=msg): X_oof = encoder.fit_transform(df, y) assert X_oof["cat"].isnull().any() From b845ac4b744d4a17344718de0ebc6dff6036edc0 Mon Sep 17 00:00:00 2001 From: Juss Patel <6782.stkabirdio@gmail.com> Date: Fri, 2 Oct 2026 18:07:23 +0530 Subject: [PATCH 3/3] sparse matrix check fix and OOF consistency check fix for py311-sklearn150 --- tests/estimator_checks/sklearn_check_wrapper.py | 4 ++++ tests/test_encoding/test_check_estimator_encoders.py | 4 +++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/estimator_checks/sklearn_check_wrapper.py b/tests/estimator_checks/sklearn_check_wrapper.py index 5e5deb459..94340a81c 100644 --- a/tests/estimator_checks/sklearn_check_wrapper.py +++ b/tests/estimator_checks/sklearn_check_wrapper.py @@ -20,6 +20,7 @@ import numpy as np import pandas as pd +from scipy.sparse import issparse from sklearn.base import BaseEstimator, TransformerMixin, clone from sklearn.utils.validation import check_is_fitted @@ -62,6 +63,9 @@ def _to_df(X): if hasattr(X, "iloc"): return X + if issparse(X): + raise TypeError("This transformer does not support sparse matrices.") + arr = np.asarray(X) if arr.ndim == 0: raise ValueError( diff --git a/tests/test_encoding/test_check_estimator_encoders.py b/tests/test_encoding/test_check_estimator_encoders.py index 3083601d4..86ca3b4ba 100644 --- a/tests/test_encoding/test_check_estimator_encoders.py +++ b/tests/test_encoding/test_check_estimator_encoders.py @@ -35,7 +35,9 @@ # breaks with sklearn 1.4.1 - check and fix? # DecisionTreeEncoder(regression=False, ignore_format=True), MeanEncoder(ignore_format=True), - OOFMeanEncoder(ignore_format=True), + # OOFMeanEncoder cannot be checked directly by sklearn's check_estimator because + # check_transformer_general enforces fit_transform(X) == fit(X).transform(X), + # which by definition does not hold for out-of-fold encoders. OneHotEncoder(ignore_format=True), OrdinalEncoder(ignore_format=True), RareLabelEncoder(