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
173 changes: 121 additions & 52 deletions feature_engine/outliers/base_outlier.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
from typing import List, Literal, Optional, Union

import pandas as pd
import narwhals as nw
import narwhals.dependencies as nwd
import numpy as np
from narwhals.typing import IntoDataFrame, IntoSeries
from sklearn.base import BaseEstimator, TransformerMixin
from sklearn.utils.validation import check_is_fitted

Expand All @@ -27,72 +30,112 @@
class BaseOutlier(TransformerMixin, BaseEstimator, GetFeatureNamesOutMixin):
"""shared set-up checks and methods across outlier transformers"""

def _check_transform_input_and_state(self, X: pd.DataFrame) -> pd.DataFrame:
def _check_transform_input_and_state(self, X: IntoDataFrame) -> IntoDataFrame:
"""Checks that the input is a dataframe and of the same size as the one used
in the fit method. Checks absence of NA.

Parameters
----------
X: pandas DataFrame
X: dataframe

Raises
------
TypeError
If the input is not a pandas DataFrame
If the input is not a recognised dataframe
ValueError
If the dataframe is not of same size as that used in fit()

Returns
-------
X: pandas DataFrame
The same dataframe entered by the user.
nw_X: narwhals dataframe
The narwhalified version of the dataframe entered by the user, with
the variables in the same order as in the train set.
"""
# check if class was fitted
check_is_fitted(self)

# check that input is a dataframe
X = check_X(X)
nw_X = check_X(X)

# Check that the dataframe contains the same number of columns
# than the dataframe used to fit the imputer.
# than the dataframe used to fit the transformer.
_check_X_matches_training_df(X, self.n_features_in_)

if self.missing_values == "raise":
# check if dataset contains na
_check_contains_na(X, self.variables_)
_check_contains_inf(X, self.variables_)

# reorder to match training set
X = X[self.feature_names_in_]
# reorder to match training set. pandas selects by label, which also
# supports integer column names.
if nwd.is_pandas_dataframe(X):
return nw.from_native(X[self.feature_names_in_], eager_only=True)
return nw_X.select(nw.col(*self.feature_names_in_))

return X

def _transform(self, X: pd.DataFrame) -> pd.DataFrame:
def _transform(self, X: IntoDataFrame) -> IntoDataFrame:
"""
Cap the variable values.

Parameters
----------
X: pandas dataframe of shape = [n_samples, n_features]
X: dataframe of shape = [n_samples, n_features]
The data to be transformed.

Returns
-------
X_new: pandas dataframe of shape = [n_samples, n_features]
X_new: dataframe of shape = [n_samples, n_features]
The dataframe with the capped variables.
"""

# check if class was fitted
X = self._check_transform_input_and_state(X)

# replace outliers
for feature in self.right_tail_caps_.keys():
X[feature] = X[feature].clip(upper=self.right_tail_caps_[feature])

for feature in self.left_tail_caps_.keys():
X[feature] = X[feature].clip(lower=self.left_tail_caps_[feature])

return X
nw_X = self._check_transform_input_and_state(X)

# infinite limits don't cap, and clipping to them turns integers into floats
right = {v: c for v, c in self.right_tail_caps_.items() if np.isfinite(c)}
left = {v: c for v, c in self.left_tail_caps_.items() if np.isfinite(c)}

both = [var for var in self.variables_ if var in right and var in left]
right_only = [
var for var in self.variables_ if var in right and var not in left
]
left_only = [var for var in self.variables_ if var in left and var not in right]

# Grouping columns by which bound(s) apply turns the per-column .clip()
# loop into up to 3 vectorized numpy calls (benchmarked 2-6x faster than
# pandas-native at 10k-100k rows). Using np.clip/minimum/maximum only with
# the bounds that actually apply (never an inf sentinel for a missing
# side) keeps int-dtype columns int, matching pandas .clip() exactly.
new_series = []
if len(both) > 0:
values = nw_X.select(nw.col(*both)).to_numpy()
lower = np.array([left[var] for var in both])
upper = np.array([right[var] for var in both])
clipped = np.clip(values, lower, upper)
new_series += [
nw.new_series(var, clipped[:, i], backend=nw_X.implementation)
for i, var in enumerate(both)
]
if len(right_only) > 0:
values = nw_X.select(nw.col(*right_only)).to_numpy()
upper = np.array([right[var] for var in right_only])
clipped = np.minimum(values, upper)
new_series += [
nw.new_series(var, clipped[:, i], backend=nw_X.implementation)
for i, var in enumerate(right_only)
]
if len(left_only) > 0:
values = nw_X.select(nw.col(*left_only)).to_numpy()
lower = np.array([left[var] for var in left_only])
clipped = np.maximum(values, lower)
new_series += [
nw.new_series(var, clipped[:, i], backend=nw_X.implementation)
for i, var in enumerate(left_only)
]

if len(new_series) > 0:
nw_X = nw_X.with_columns(*new_series)

return nw_X.to_native()

def _more_tags(self):
tags_dict = _return_tags()
Expand Down Expand Up @@ -205,21 +248,21 @@ def __init__(
self.return_empty = return_empty
self.missing_values = missing_values

def fit(self, X: pd.DataFrame, y: Optional[pd.Series] = None):
def fit(self, X: IntoDataFrame, y: Optional[IntoSeries] = None):
"""
Learn the values that should be used to replace outliers.

Parameters
----------
X : pandas dataframe of shape = [n_samples, n_features]
X : dataframe of shape = [n_samples, n_features]
The training input samples.

y : pandas Series, default=None
y : Series, default=None
y is not needed in this transformer. You can pass y or None.
"""

# check input dataframe
X = check_X(X)
nw_X = check_X(X)

# find or check for numerical variables
if self.variables is None:
Expand All @@ -242,49 +285,75 @@ def fit(self, X: pd.DataFrame, y: Optional[pd.Series] = None):
else:
self.fold_ = self.fold

values = nw_X.select(nw.col(*self.variables_)).to_numpy()

# nan-aware reductions: with missing_values="ignore", values may contain
# NaN, and pandas' mean/std/quantile/median skip NaN by default.
if self.capping_method == "gaussian":
bias = X[self.variables_].mean()
scale = X[self.variables_].std(ddof=0)
bias = np.nanmean(values, axis=0)
scale = np.nanstd(values, axis=0, ddof=0)
elif self.capping_method == "iqr":
bias = X[self.variables_].quantile((0.75, 0.25))
scale = bias.loc[0.75] - bias.loc[0.25]
q75 = np.nanquantile(values, 0.75, axis=0)
q25 = np.nanquantile(values, 0.25, axis=0)
scale = q75 - q25
elif self.capping_method == "quantiles":
bias = X[self.variables_].quantile((1 - self.fold_, self.fold_))
scale = bias.loc[1 - self.fold_] - bias.loc[self.fold_]
q_hi = np.nanquantile(values, 1 - self.fold_, axis=0)
q_lo = np.nanquantile(values, self.fold_, axis=0)
scale = q_hi - q_lo
elif self.capping_method == "mad":
bias = X[self.variables_].median()
bias = np.nanmedian(values, axis=0)
# scaling factor for normal distribution
scale = (X[self.variables_] - bias).abs().median() / 0.67449
if (scale == 0).any():
raise ValueError(
f"Input columns {scale[scale == 0].index.tolist()!r}"
f" have low variation for method {self.capping_method!r}."
f" Try other capping methods or drop these columns."
)
scale = np.nanmedian(np.abs(values - bias), axis=0) / 0.67449

# estimate the end values
if self.tail in ("right", "both"):
if self.capping_method in ("gaussian", "mad"):
self.right_tail_caps_ = (bias + self.fold_ * scale).to_dict()
self.right_tail_caps_ = {
var: float(b + self.fold_ * s)
for var, b, s in zip(self.variables_, bias, scale)
}

elif self.capping_method == "iqr":
self.right_tail_caps_ = (bias.loc[0.75] + self.fold_ * scale).to_dict()
self.right_tail_caps_ = {
var: float(q + self.fold_ * s)
for var, q, s in zip(self.variables_, q75, scale)
}

elif self.capping_method == "quantiles":
self.right_tail_caps_ = bias.loc[1 - self.fold_].to_dict()
self.right_tail_caps_ = {
var: float(q) for var, q in zip(self.variables_, q_hi)
}

if self.tail in ("left", "both"):
if self.capping_method in ("gaussian", "mad"):
self.left_tail_caps_ = (bias - self.fold_ * scale).to_dict()
self.left_tail_caps_ = {
var: float(b - self.fold_ * s)
for var, b, s in zip(self.variables_, bias, scale)
}

elif self.capping_method == "iqr":
self.left_tail_caps_ = (bias.loc[0.25] - self.fold_ * scale).to_dict()
self.left_tail_caps_ = {
var: float(q - self.fold_ * s)
for var, q, s in zip(self.variables_, q25, scale)
}

elif self.capping_method == "quantiles":
self.left_tail_caps_ = bias.loc[self.fold_].to_dict()

self.feature_names_in_ = X.columns.to_list()
self.n_features_in_ = X.shape[1]
self.left_tail_caps_ = {
var: float(q) for var, q in zip(self.variables_, q_lo)
}

# variables without variation have no outliers, so they get infinite limits
for var, s in zip(self.variables_, scale):
if s == 0:
if var in self.right_tail_caps_:
self.right_tail_caps_[var] = float("inf")
if var in self.left_tail_caps_:
self.left_tail_caps_[var] = float("-inf")

# list() normalises both a narwhals `.columns` (already a list) and a
# pandas Index to a plain list.
self.feature_names_in_ = list(nw_X.columns)
self.n_features_in_ = nw_X.shape[1]

return self

Expand Down
45 changes: 45 additions & 0 deletions tests/test_outliers/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
"""Data shared by the outlier transformer tests.

Each fixture returns a fresh dict, so tests can build the dataframe on the
backend under test with ``make_df(data)``. Missing values are written as None,
which both pandas and polars read as missing.
"""

import numpy as np
import pytest


@pytest.fixture
def data_normal_dist():
# same seed and parameters as the pandas df_normal_dist fixture in
# tests/conftest.py
return {"var": np.random.RandomState(0).normal(0, 0.1, 100).tolist()}


@pytest.fixture
def data_na():
return {
"Name": ["tom", "nick", "krish", None, "peter", None, "fred", "sam"],
"City": [
"London",
"Manchester",
None,
None,
"London",
"London",
"Bristol",
"Manchester",
],
"Studies": [
"Bachelor",
"Bachelor",
None,
None,
"Bachelor",
"PhD",
"None",
"Masters",
],
"Age": [20, 21, 19, None, 23, 40, 41, 37],
"Marks": [0.9, 0.8, 0.7, None, 0.3, None, 0.8, 0.6],
}
Loading