Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
124 changes: 123 additions & 1 deletion feature_engine/dataframe_checks.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,12 @@
transform().
"""

from typing import List, Union
from typing import List, Union, Tuple

import numpy as np
import pandas as pd
from scipy.sparse import issparse
from sklearn.utils.validation import _check_y, check_consistent_length


def check_X(X: Union[np.generic, np.ndarray, pd.DataFrame]) -> pd.DataFrame:
Expand Down Expand Up @@ -85,6 +86,127 @@ def check_X(X: Union[np.generic, np.ndarray, pd.DataFrame]) -> pd.DataFrame:
return X


def check_y(
y: Union[np.generic, np.ndarray, pd.Series, List],
multi_output: bool = False,
y_numeric: bool = False,
) -> pd.Series:
"""
Checks that y is a series, or alternatively, if it can be converted to a series.

Parameters
----------
y : pd.Series, np.array, list
The input to check and copy or transform.

multi_output : bool, default=False
Whether to allow 2D y (array). If false, y will be
validated as a vector. y cannot have np.nan or np.inf values if
multi_output=True.

y_numeric : bool, default=False

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

default in docstring disagrees with code default

Whether to ensure that y has a numeric type. If dtype of y is object,
it is converted to float64. Should only be used for regression
algorithms.

Returns
-------
y: pd.Series
"""

if y is None:
raise ValueError("y cannot be None.")

elif isinstance(y, pd.Series):
if y.isnull().any():
raise ValueError("y contains NaN values.")
if y.dtype != "O" and not np.isfinite(y).all():
raise ValueError("y contains infinity values.")
if y_numeric and y.dtype == "O":
y = y.astype("float")
y = y.copy()

else:
y = _check_y(y, multi_output=multi_output, y_numeric=y_numeric)
y = pd.Series(y)

return y


def check_X_y(
X: Union[np.generic, np.ndarray, pd.DataFrame],
y: Union[np.generic, np.ndarray, pd.Series, List],
multi_output: bool = False,
y_numeric: bool = False,
) -> Tuple[pd.DataFrame, pd.Series]:
"""
Ensures X and y are compatible pandas DataFrame and Series. If both are pandas
objects, checks that their indexes match. If any is a numpy array, converts to
pandas object with compatible index.

This transformer ensures that we can concatenate X and y using `pandas.concat`,
functionality needed in the encoders.

Parameters
----------
X: Pandas DataFrame or numpy ndarray
The input to check and copy or transform.

y: pd.Series, np.array, list
The input to check and copy or transform.

multi_output : bool, default=False
Whether to allow 2D y (array). If false, y will be
validated as a vector. y cannot have np.nan or np.inf values if
multi_output=True.

y_numeric : bool, default=False
Whether to ensure that y has a numeric type. If dtype of y is object,
it is converted to float64. Should only be used for regression
algorithms.

Raises
------
ValueError: if X and y are pandas objects with inconsistent indexes.
TypeError: if X is sparse matrix, empty dataframe or not a dataframe.
TypeError: if y can't be parsed as pandas Series.

Returns
-------
X: Pandas DataFrame
y: Pandas Series
"""

def _check_X_y(X, y):
X = check_X(X)
y = check_y(y, multi_output=multi_output, y_numeric=y_numeric)
check_consistent_length(X, y)
return X, y
Comment on lines +180 to +184

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In every case below you call this function, so consider just running this code first without defining it as a local function.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, makes sense. The thing is, if I ran this function first, then X and y would be a pandas df and series, so I can't really check for the different scenarios any more. That's why I took this convoluted approach instead.

How would you approach it?

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, of course. This is fine then. You could also maybe define "the" index in cases, then do the checks/conversions?


# case 1: both are pandas objects
if isinstance(X, pd.DataFrame) and isinstance(y, pd.Series):
X, y = _check_X_y(X, y)
# Check that their indexes match.
if not all(y.index == X.index):
raise ValueError("The indexes of X and y do not match.")

# case 2: X is dataframe and y is something else
if isinstance(X, pd.DataFrame) and not isinstance(y, pd.Series):
X, y = _check_X_y(X, y)
y.index = X.index

# case 3: X is not a dataframe and y is a series
elif not isinstance(X, pd.DataFrame) and isinstance(y, pd.Series):
X, y = _check_X_y(X, y)
X.index = y.index

# all other cases
else:
X, y = _check_X_y(X, y)

return X, y


def _check_X_matches_training_df(X: pd.DataFrame, reference: int) -> None:
"""
Checks that DataFrame to transform has the same number of columns that the
Expand Down
34 changes: 15 additions & 19 deletions feature_engine/encoding/base_encoder.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import warnings
from typing import List, Union
from typing import List, Union, Tuple

import pandas as pd
from sklearn.base import BaseEstimator, TransformerMixin
Expand All @@ -9,6 +9,7 @@
_check_contains_na,
_check_X_matches_training_df,
check_X,
check_X_y,
)
from feature_engine.docstrings import Substitution
from feature_engine.encoding._docstrings import (
Expand Down Expand Up @@ -53,10 +54,18 @@ def __init__(
self.variables = _check_input_parameter_variables(variables)
self.ignore_format = ignore_format

def _check_fit_input_and_variables(self, X: pd.DataFrame) -> pd.DataFrame:
def _check_X(self, X: pd.DataFrame) -> pd.DataFrame:
return check_X(X)

def _check_X_y(
self, X: pd.DataFrame, y: pd.Series
) -> Tuple[pd.DataFrame, pd.Series]:
return check_X_y(X, y)

def _check_or_select_variables(self, X: pd.DataFrame):
"""
Checks that input is a dataframe, finds categorical variables, or alternatively
checks that the variables entered by the user are of type object (categorical).
Finds categorical variables, or alternatively checks that the variables
entered by the user are of type object (categorical).
Checks absence of NA.

Parameters
Expand All @@ -66,23 +75,11 @@ def _check_fit_input_and_variables(self, X: pd.DataFrame) -> pd.DataFrame:
Raises
------
TypeError
If the input is not a Pandas DataFrame.
If any user provided variable is not categorical
ValueError
If there are no categorical variables in the df or the df is empty
If the variable(s) contain null values

Returns
-------
X: Pandas DataFrame
The same dataframe entered as parameter
variables : list
list of categorical variables
"""

# check input dataframe
X = check_X(X)

if not self.ignore_format:
# find categorical variables or check variables entered by user are object
self.variables_: List[
Expand All @@ -95,14 +92,14 @@ def _check_fit_input_and_variables(self, X: pd.DataFrame) -> pd.DataFrame:
# check if dataset contains na
_check_contains_na(X, self.variables_)

def _get_feature_names_in(self, X: pd.DataFrame):

# save input features
self.feature_names_in_ = X.columns.tolist()

# save train set shape
self.n_features_in_ = X.shape[1]

return X

def _check_transform_input_and_state(self, X: pd.DataFrame) -> pd.DataFrame:
"""
Checks that the input is a dataframe and of the same size than the one used
Expand Down Expand Up @@ -283,7 +280,6 @@ def __init__(
ignore_format: bool = False,
errors: str = "ignore",
) -> None:

if errors not in ["raise", "ignore"]:
raise ValueError(
"errors takes only values 'raise' and 'ignore ."
Expand Down
5 changes: 3 additions & 2 deletions feature_engine/encoding/count_frequency.py
Original file line number Diff line number Diff line change
Expand Up @@ -138,8 +138,9 @@ def fit(self, X: pd.DataFrame, y: Optional[pd.Series] = None):
y: pandas Series, default = None
y is not needed in this encoder. You can pass y or None.
"""

X = self._check_fit_input_and_variables(X)
X = self._check_X(X)
self._check_or_select_variables(X)
self._get_feature_names_in(X)

self.encoder_dict_ = {}

Expand Down
5 changes: 3 additions & 2 deletions feature_engine/encoding/decision_tree.py
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,7 @@ def fit(self, X: pd.DataFrame, y: pd.Series):
The target variable. Required to train the decision tree and for
ordered ordinal encoding.
"""
X, y = self._check_X_y(X, y)

# confirm model type and target variables are compatible.
if self.regression is True:
Expand All @@ -201,8 +202,8 @@ def fit(self, X: pd.DataFrame, y: pd.Series):
else:
check_classification_targets(y)

# check input dataframe
X = self._check_fit_input_and_variables(X)
self._check_or_select_variables(X)
self._get_feature_names_in(X)

if self.param_grid:
param_grid = self.param_grid
Expand Down
7 changes: 3 additions & 4 deletions feature_engine/encoding/mean_encoding.py
Original file line number Diff line number Diff line change
Expand Up @@ -131,10 +131,9 @@ def fit(self, X: pd.DataFrame, y: pd.Series):
The target.
"""

X = self._check_fit_input_and_variables(X)

if not isinstance(y, pd.Series):
y = pd.Series(y)
X, y = self._check_X_y(X, y)
self._check_or_select_variables(X)
self._get_feature_names_in(X)

temp = pd.concat([X, y], axis=1)
temp.columns = list(X.columns) + ["target"]
Expand Down
4 changes: 3 additions & 1 deletion feature_engine/encoding/one_hot.py
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,9 @@ def fit(self, X: pd.DataFrame, y: Optional[pd.Series] = None):
None.
"""

X = self._check_fit_input_and_variables(X)
X = self._check_X(X)
self._check_or_select_variables(X)
self._get_feature_names_in(X)

self.encoder_dict_ = {}

Expand Down
13 changes: 6 additions & 7 deletions feature_engine/encoding/ordinal.py
Original file line number Diff line number Diff line change
Expand Up @@ -146,16 +146,15 @@ def fit(self, X: pd.DataFrame, y: Optional[pd.Series] = None):
Otherwise, y needs to be passed when fitting the transformer.
"""

X = self._check_fit_input_and_variables(X)

# join target to predictor variables
if self.encoding_method == "ordered":
if y is None:
raise ValueError("Please provide a target y for this encoding method")
X, y = self._check_X_y(X, y)
else:
X = self._check_X(X)

if not isinstance(y, pd.Series):
y = pd.Series(y)
self._check_or_select_variables(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"]

Expand Down
8 changes: 4 additions & 4 deletions feature_engine/encoding/probability_ratio.py
Original file line number Diff line number Diff line change
Expand Up @@ -154,10 +154,7 @@ def fit(self, X: pd.DataFrame, y: pd.Series):
Target, must be binary.
"""

X = self._check_fit_input_and_variables(X)

if not isinstance(y, pd.Series):
y = pd.Series(y)
X, y = self._check_X_y(X, y)

# check that y is binary
if y.nunique() != 2:
Expand All @@ -166,6 +163,9 @@ def fit(self, X: pd.DataFrame, y: pd.Series):
"used has more than 2 unique values."
)

self._check_or_select_variables(X)
self._get_feature_names_in(X)

temp = pd.concat([X, y], axis=1)
temp.columns = list(X.columns) + ["target"]

Expand Down
4 changes: 3 additions & 1 deletion feature_engine/encoding/rare_label.py
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,9 @@ def fit(self, X: pd.DataFrame, y: Optional[pd.Series] = None):
y is not required. You can pass y or None.
"""

X = self._check_fit_input_and_variables(X)
X = self._check_X(X)
self._check_or_select_variables(X)
self._get_feature_names_in(X)

self.encoder_dict_ = {}

Expand Down
8 changes: 4 additions & 4 deletions feature_engine/encoding/woe.py
Original file line number Diff line number Diff line change
Expand Up @@ -136,10 +136,7 @@ def fit(self, X: pd.DataFrame, y: pd.Series):
Target, must be binary.
"""

X = self._check_fit_input_and_variables(X)

if not isinstance(y, pd.Series):
y = pd.Series(y)
X, y = self._check_X_y(X, y)

# check that y is binary
if y.nunique() != 2:
Expand All @@ -148,6 +145,9 @@ def fit(self, X: pd.DataFrame, y: pd.Series):
"used has more than 2 unique values."
)

self._check_or_select_variables(X)
self._get_feature_names_in(X)

temp = pd.concat([X, y], axis=1)
temp.columns = list(X.columns) + ["target"]

Expand Down
Loading