Skip to content
Open
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
56 changes: 56 additions & 0 deletions docs/user_guide/outliers/Winsoriser.rst
Original file line number Diff line number Diff line change
Expand Up @@ -354,6 +354,62 @@ The default values for fold are as follows:
You can manually adjust the `fold` value to make the outlier detection process more or less
conservative, thus customising the extent of outlier capping.

With polars
-----------

:class:`Winsoriser()` works in the same way with a polars dataframe, including the
`add_indicators` option, which flags the rows that were capped on each tail:

.. code:: python

import polars as pl
from feature_engine.outliers import Winsoriser

df = pl.DataFrame({
"Age": [20, 21, 19, 18, 23, 40, 41, 97],
"Marks": [0.9, 0.8, 0.7, 0.6, 0.3, 0.5, 0.8, 0.05],
})

transformer = Winsoriser(
capping_method="iqr", tail="both", fold=1.5, add_indicators=True,
)
transformer.fit(df)

print(transformer.right_tail_caps_)
print(transformer.left_tail_caps_)

The learned capping values match those found with pandas:

.. code:: text

{'Age': 71.0, 'Marks': 1.3250000000000002}
{'Age': -11.0, 'Marks': -0.07500000000000001}

.. code:: python

print(transformer.transform(df))

`Age`'s outlier, 97, was capped to 71 and flagged in `Age_right`; none of the values
in `Marks` were extreme enough to be capped:

.. code:: text

shape: (8, 6)
┌──────┬───────┬──────────┬───────────┬────────────┬─────────────┐
│ Age ┆ Marks ┆ Age_left ┆ Age_right ┆ Marks_left ┆ Marks_right │
│ --- ┆ --- ┆ --- ┆ --- ┆ --- ┆ --- │
│ f64 ┆ f64 ┆ f64 ┆ f64 ┆ f64 ┆ f64 │
╞══════╪═══════╪══════════╪═══════════╪════════════╪═════════════╡
│ 20.0 ┆ 0.9 ┆ 0.0 ┆ 0.0 ┆ 0.0 ┆ 0.0 │
│ 21.0 ┆ 0.8 ┆ 0.0 ┆ 0.0 ┆ 0.0 ┆ 0.0 │
│ 19.0 ┆ 0.7 ┆ 0.0 ┆ 0.0 ┆ 0.0 ┆ 0.0 │
│ 18.0 ┆ 0.6 ┆ 0.0 ┆ 0.0 ┆ 0.0 ┆ 0.0 │
│ 23.0 ┆ 0.3 ┆ 0.0 ┆ 0.0 ┆ 0.0 ┆ 0.0 │
│ 40.0 ┆ 0.5 ┆ 0.0 ┆ 0.0 ┆ 0.0 ┆ 0.0 │
│ 41.0 ┆ 0.8 ┆ 0.0 ┆ 0.0 ┆ 0.0 ┆ 0.0 │
│ 71.0 ┆ 0.05 ┆ 0.0 ┆ 1.0 ┆ 0.0 ┆ 0.0 │
└──────┴───────┴──────────┴───────────┴────────────┴─────────────┘

Additional resources
--------------------

Expand Down
158 changes: 123 additions & 35 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,24 +30,24 @@
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
X: dataframe.
The same dataframe entered by the user.
"""
# check if class was fitted
Expand All @@ -54,7 +57,7 @@ def _check_transform_input_and_state(self, X: pd.DataFrame) -> pd.DataFrame:
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":
Expand All @@ -63,34 +66,88 @@ def _check_transform_input_and_state(self, X: pd.DataFrame) -> pd.DataFrame:
_check_contains_inf(X, self.variables_)

# reorder to match training set
X = X[self.feature_names_in_]
is_pandas = nwd.is_pandas_dataframe(X)
if is_pandas is True:
X = X[self.feature_names_in_]
else:
X = (
nw.from_native(X, eager_only=True)
.select(nw.col(*self.feature_names_in_))
.to_native()
)

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])
nw_X = nw.from_native(X, eager_only=True)

both = [
var
for var in self.variables_
if var in self.right_tail_caps_ and var in self.left_tail_caps_
]
right_only = [
var
for var in self.variables_
if var in self.right_tail_caps_ and var not in self.left_tail_caps_
]
left_only = [
var
for var in self.variables_
if var in self.left_tail_caps_ and var not in self.right_tail_caps_
]

# 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([self.left_tail_caps_[var] for var in both])
upper = np.array([self.right_tail_caps_[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([self.right_tail_caps_[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([self.left_tail_caps_[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:
X = nw_X.with_columns(*new_series).to_native()

return X

Expand Down Expand Up @@ -205,16 +262,16 @@ 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.
"""

Expand Down Expand Up @@ -242,48 +299,79 @@ def fit(self, X: pd.DataFrame, y: Optional[pd.Series] = None):
else:
self.fold_ = self.fold

nw_X = nw.from_native(X, eager_only=True)
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
scale = np.nanmedian(np.abs(values - bias), axis=0) / 0.67449

if (scale == 0).any():
failing_vars = [
var for var, s in zip(self.variables_, scale) if s == 0
]
raise ValueError(
f"Input columns {scale[scale == 0].index.tolist()!r}"
f"Input columns {failing_vars!r}"
f" have low variation for method {self.capping_method!r}."
f" Try other capping methods or drop these columns."
)

# 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.left_tail_caps_ = {
var: float(q) for var, q in zip(self.variables_, q_lo)
}

self.feature_names_in_ = X.columns.to_list()
is_pandas = nwd.is_pandas_dataframe(X)
if is_pandas is True:
self.feature_names_in_ = list(X.columns)
else:
self.feature_names_in_ = nw_X.columns
self.n_features_in_ = X.shape[1]

return self
Expand Down
Loading