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/encoding/OrdinalEncoder.rst
Original file line number Diff line number Diff line change
Expand Up @@ -532,6 +532,62 @@ might otherwise go unnoticed.
The power of ordinal ordered encoder resides in its intrinsic capacity of finding monotonic relationships.


With polars
~~~~~~~~~~~

:class:`OrdinalEncoder()` works the same way with a polars dataframe. Let's create a toy dataset:

.. code:: python

import polars as pl
from feature_engine.encoding import OrdinalEncoder

X = pl.DataFrame({
"city": ["London", "Manchester", "Liverpool", "London", "Manchester", "Liverpool"],
"price": [500, 300, 250, 520, 310, 260],
})
y = pl.Series("target", [1, 0, 0, 1, 0, 1])

Let's set up :class:`OrdinalEncoder()` to encode `city` with ordered ordinal encoding, and fit it to the data:

.. code:: python

encoder = OrdinalEncoder(encoding_method="ordered", variables=["city"])
encoder.fit(X, y)

encoder.encoder_dict_

We see the resulting mappings from category to integer:

.. code:: python

{'city': {'Manchester': 0, 'Liverpool': 1, 'London': 2}}

Now let's transform the data:

.. code:: python

encoder.transform(X)

We obtain a polars dataframe with the categories in `city` replaced by their ordinal number:

.. code:: text

shape: (6, 2)
┌──────┬───────┐
│ city ┆ price │
│ --- ┆ --- │
│ i64 ┆ i64 │
╞══════╪═══════╡
│ 2 ┆ 500 │
│ 0 ┆ 300 │
│ 1 ┆ 250 │
│ 2 ┆ 520 │
│ 0 ┆ 310 │
│ 1 ┆ 260 │
└──────┴───────┘


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

Expand Down
107 changes: 86 additions & 21 deletions feature_engine/encoding/ordinal.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@

from typing import List, Optional, Union

import pandas as pd
import narwhals as nw
import narwhals.dependencies as nwd
from narwhals.typing import IntoDataFrame, IntoSeries

from feature_engine._check_init_parameters.check_init_input_params import (
_check_return_empty_is_bool,
Expand Down Expand Up @@ -190,48 +192,111 @@ def __init__(
self.unseen = unseen
self.return_empty = return_empty

def fit(self, X: pd.DataFrame, y: Optional[pd.Series] = None):
def fit(self, X: IntoDataFrame, y: Optional[IntoSeries] = None):
"""Learn the numbers to be used to replace the categories in each
variable.

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
variables to be encoded.

y: pandas series, default=None
y: Series, default=None
The Target. Can be None if `encoding_method='arbitrary'`.
Otherwise, y needs to be passed when fitting the transformer.
"""

if self.encoding_method == "ordered":
X, y = check_X_y(X, y)
nw_X, y = check_X_y(X, y)
else:
X = check_X(X)
nw_X = check_X(X)

variables_ = self._check_or_select_variables(X)
self._check_na(X, variables_)

self.encoder_dict_ = {}

for var in variables_:
# benchmarked at 10k-100k rows x 1-10 cols x 5-50 categories: a pure
# narwhals fit() ran 5x-18x slower than pandas-native here (unlike
# the encode/transform hot path in base_encoder.py, which is only
# ~1.1x), so pandas keeps its native groupby/unique fast path and
# only polars (and other backends) go through narwhals.
if nwd.is_pandas_dataframe(X):
for var in variables_:
if self.encoding_method == "ordered":
if nwd.is_pandas_series(y):
t = y.groupby(X[var], observed=False).mean() # type: ignore
else:
# y is a numpy array here (e.g. list/array-like input
# went through sklearn's column_or_1d instead of
# check_X_y's Series passthrough); it has no
# .groupby(), so pair it with X[var] positionally via
# assign() instead - this also matches how the
# narwhals branch below handles a non-Series y.
t = (
X[[var]]
.assign(__feature_engine_ordinal_target__=y)
.groupby(var, observed=False)[
"__feature_engine_ordinal_target__"
]
.mean()
)
t = t.sort_values(ascending=True).index
elif self.encoding_method == "arbitrary":
if self.missing_values == "ignore":
t = X[var].dropna().unique()
else:
t = X[var].unique()
else:
raise ValueError(
"Unrecognized value for encoding_method. It should be "
f"'arbitrary' or 'frequency'. Got {self.encoding_method} "
"instead."
)
self.encoder_dict_[var] = {k: i for i, k in enumerate(t, 0)}
else:
if self.encoding_method == "ordered":
t = y.groupby(X[var], observed=False).mean() # type: ignore
t = t.sort_values(ascending=True).index

elif self.encoding_method == "arbitrary":
if self.missing_values == "ignore":
t = X[var].dropna().unique()
# y may already be a Series (polars, from check_X_y) or a
# plain numpy array (sklearn's column_or_1d path for
# list/array input) - normalise both to a narwhals Series
# aliased to a sentinel name, then attach it to the full
# frame once so every variable's group_by below can reuse it.
target_name = "__feature_engine_ordinal_target__"
if nwd.is_into_series(y):
y_nw = nw.from_native(y, series_only=True).alias(target_name)
else:
y_nw = nw.new_series(
name=target_name, values=y, backend=nw_X.implementation
)
nw_Xy = nw_X.with_columns(y_nw)

for var in variables_:
if self.encoding_method == "ordered":
# sort by (mean, category): group_by's own order isn't
# guaranteed across backends, and this tie-break on the
# category itself reproduces pandas' groupby(sort=True)
# + stable sort_values behavior for categories with equal
# target means.
t = (
nw_Xy.group_by(var, drop_null_keys=True)
.agg(nw.col(target_name).mean())
.sort([target_name, var])
.get_column(var)
.to_list()
)
elif self.encoding_method == "arbitrary":
col = nw_X.get_column(var)
if self.missing_values == "ignore":
col = col.drop_nulls()
t = col.unique(maintain_order=True).to_list()
else:
t = X[var].unique()
else:
raise ValueError(
"Unrecognized value for encoding_method. It should be 'arbitrary' "
f"or 'frequency'. Got {self.encoding_method} instead."
)

self.encoder_dict_[var] = {k: i for i, k in enumerate(t, 0)}
raise ValueError(
"Unrecognized value for encoding_method. It should be "
f"'arbitrary' or 'frequency'. Got {self.encoding_method} "
"instead."
)
self.encoder_dict_[var] = {k: i for i, k in enumerate(t, 0)}

if self.unseen == "encode":
self._unseen = -1
Expand Down
Loading