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
54 changes: 54 additions & 0 deletions docs/user_guide/creation/CyclicalFeatures.rst
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,60 @@ This returns the name of all the variables in the final output:
['day_sin', 'day_cos', 'months_sin', 'months_cos']


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

:class:`CyclicalFeatures()` works in the same way with a polars dataframe.
Let's create an equivalent toy dataframe:

.. code:: python

import polars as pl
from feature_engine.creation import CyclicalFeatures

df = pl.DataFrame({
"day": [6, 7, 5, 3, 1, 2, 4],
"months": [3, 7, 9, 12, 4, 6, 12],
})

cyclical = CyclicalFeatures(variables=None, drop_original=False)
X = cyclical.fit_transform(df)

cyclical.max_values_

The maximum values match those found with pandas:

.. code:: python

{'day': 7, 'months': 12}

And the transformed dataframe contains the same cyclical features:

.. code:: python

print(X)

.. code:: text

shape: (7, 6)
┌─────┬────────┬─────────────┬───────────┬─────────────┬─────────────┐
│ day ┆ months ┆ day_sin ┆ day_cos ┆ months_sin ┆ months_cos │
│ --- ┆ --- ┆ --- ┆ --- ┆ --- ┆ --- │
│ i64 ┆ i64 ┆ f64 ┆ f64 ┆ f64 ┆ f64 │
╞═════╪════════╪═════════════╪═══════════╪═════════════╪═════════════╡
│ 6 ┆ 3 ┆ -0.781831 ┆ 0.62349 ┆ 1.0 ┆ 6.1232e-17 │
│ 7 ┆ 7 ┆ -2.4493e-16 ┆ 1.0 ┆ -0.5 ┆ -0.866025 │
│ 5 ┆ 9 ┆ -0.974928 ┆ -0.222521 ┆ -1.0 ┆ -1.8370e-16 │
│ 3 ┆ 12 ┆ 0.433884 ┆ -0.900969 ┆ -2.4493e-16 ┆ 1.0 │
│ 1 ┆ 4 ┆ 0.781831 ┆ 0.62349 ┆ 0.866025 ┆ -0.5 │
│ 2 ┆ 6 ┆ 0.974928 ┆ -0.222521 ┆ 1.2246e-16 ┆ -1.0 │
│ 4 ┆ 12 ┆ -0.433884 ┆ -0.900969 ┆ -2.4493e-16 ┆ 1.0 │
└─────┴────────┴─────────────┴───────────┴─────────────┴─────────────┘

`drop_original=True` and `get_feature_names_out()` work identically to the
pandas example above.


Understanding cyclical encoding
-------------------------------

Expand Down
69 changes: 55 additions & 14 deletions feature_engine/creation/cyclical_features.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
from typing import Dict, List, Optional, Union

import narwhals as nw
import numpy as np
import pandas as pd
from narwhals.typing import IntoDataFrame, IntoSeries

from feature_engine._base_transformers.base_numerical import BaseNumericalTransformer
from feature_engine._base_transformers.mixins import (
Expand Down Expand Up @@ -122,6 +123,30 @@ class CyclicalFeatures(
5 2 1.224647e-16 -1.000000e+00
6 1 1.000000e+00 6.123234e-17
7 2 1.224647e-16 -1.000000e+00

With polars:

>>> import polars as pl
>>> from feature_engine.creation import CyclicalFeatures
>>> X = pl.DataFrame({"x": [1, 4, 3, 3, 4, 2, 1, 2]})
>>> cf = CyclicalFeatures()
>>> cf.fit(X)
>>> cf.transform(X)
shape: (8, 3)
┌─────┬─────────────┬─────────────┐
│ x ┆ x_sin ┆ x_cos │
│ --- ┆ --- ┆ --- │
│ i64 ┆ f64 ┆ f64 │
╞═════╪═════════════╪═════════════╡
│ 1 ┆ 1.0 ┆ 6.1232e-17 │
│ 4 ┆ -2.4493e-16 ┆ 1.0 │
│ 3 ┆ -1.0 ┆ -1.8370e-16 │
│ 3 ┆ -1.0 ┆ -1.8370e-16 │
│ 4 ┆ -2.4493e-16 ┆ 1.0 │
│ 2 ┆ 1.2246e-16 ┆ -1.0 │
│ 1 ┆ 1.0 ┆ 6.1232e-17 │
│ 2 ┆ 1.2246e-16 ┆ -1.0 │
└─────┴─────────────┴─────────────┘
"""

def __init__(
Expand All @@ -141,22 +166,36 @@ def __init__(
self.max_values = max_values
self.drop_original = drop_original

def fit(self, X: pd.DataFrame, y: Optional[pd.Series] = None):
def fit(self, X: IntoDataFrame, y: Optional[IntoSeries] = None):
"""
Learns the maximum value of 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 transform.

y: pandas Series, default=None
y: Series, default=None
It is not needed in this transformer. You can pass y or None.
"""
if self.max_values is None:
X, variables_ = self._fit_setup(X)
max_values_ = X[variables_].max().to_dict()
if len(variables_) == 0:
# return_empty=True can leave variables_ empty; narwhals'
# select([]) collapses row count too, so .to_numpy().max()
# would fail on a genuinely empty selection.
max_values_ = {}
else:
max_arr = (
nw.from_native(X, eager_only=True)
.select(variables_)
.to_numpy()
.max(axis=0)
)
# .tolist() converts numpy scalars to plain Python int/float,
# matching the dtype .to_dict() used to return.
max_values_ = dict(zip(variables_, max_arr.tolist()))
else:
X, variables_ = super()._fit_from_dict(X, self.max_values)
max_values_ = self.max_values
Expand All @@ -167,29 +206,31 @@ def fit(self, X: pd.DataFrame, y: Optional[pd.Series] = None):

return self

def transform(self, X: pd.DataFrame):
def transform(self, X: IntoDataFrame) -> IntoDataFrame:
"""
Creates new features using the cyclical transformations.

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.
X_new: dataframe.
The original dataframe plus the additional features.
"""
X = self._check_transform_input_and_state(X)

new_cols = []
for variable in self.variables_:
max_value = self.max_values_[variable]
X[f"{variable}_sin"] = np.sin(X[variable] * (2.0 * np.pi / max_value))
X[f"{variable}_cos"] = np.cos(X[variable] * (2.0 * np.pi / max_value))

if self.drop_original:
X.drop(columns=self.variables_, inplace=True)
scaled = nw.col(variable) * (2.0 * np.pi / self.max_values_[variable])
new_cols.append(scaled.sin().alias(f"{variable}_sin"))
new_cols.append(scaled.cos().alias(f"{variable}_cos"))
nw_X = nw.from_native(X, eager_only=True).with_columns(*new_cols)
if self.drop_original is True:
nw_X = nw_X.drop(self.variables_)
X = nw_X.to_native()

return X

Expand Down
30 changes: 23 additions & 7 deletions tests/test_base_transformers/test_get_feature_names_out_mixin.py
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,10 @@ def test_with_pipe_and_skl_transformer_input_df(input_features):
df = pd.DataFrame(VARTYPES_DATA)
pipe = Pipeline(
[
("imputer", SimpleImputer(strategy="constant").set_output(transform="pandas")),
(
"imputer",
SimpleImputer(strategy="constant").set_output(transform="pandas"),
),
("transformer", MockTransformer()),
]
)
Expand Down Expand Up @@ -241,11 +244,16 @@ def test_new_feature_names_within_pipeline(make_df, features_in, input_features)
@pytest.mark.parametrize(
"input_features", [None, variables_str, np.array(variables_str)]
)
def test_new_feature_names_pipe_with_skl_transformer_and_df(features_in, input_features):
def test_new_feature_names_pipe_with_skl_transformer_and_df(
features_in, input_features
):
df = pd.DataFrame(VARTYPES_DATA)
pipe = Pipeline(
[
("imputer", SimpleImputer(strategy="constant").set_output(transform="pandas")),
(
"imputer",
SimpleImputer(strategy="constant").set_output(transform="pandas"),
),
("transformer", MockCreator(variables=features_in, drop_original=False)),
]
)
Expand All @@ -255,7 +263,10 @@ def test_new_feature_names_pipe_with_skl_transformer_and_df(features_in, input_f

pipe = Pipeline(
[
("imputer", SimpleImputer(strategy="constant").set_output(transform="pandas")),
(
"imputer",
SimpleImputer(strategy="constant").set_output(transform="pandas"),
),
("transformer", MockCreator(variables=features_in, drop_original=True)),
]
)
Expand Down Expand Up @@ -353,7 +364,10 @@ def test_remove_feature_names_pipe_with_skl_transformer_and_df(input_features):
pipe = Pipeline(
[
("transformer", MockSelector()),
("imputer", SimpleImputer(strategy="constant").set_output(transform="pandas")),
(
"imputer",
SimpleImputer(strategy="constant").set_output(transform="pandas"),
),
]
)
pipe.fit(df)
Expand All @@ -367,7 +381,10 @@ def test_remove_feature_names_pipe_with_skl_transformer_and_df(input_features):

pipe = Pipeline(
[
("imputer", SimpleImputer(strategy="constant").set_output(transform="pandas")),
(
"imputer",
SimpleImputer(strategy="constant").set_output(transform="pandas"),
),
("transformer", MockSelector()),
]
)
Expand All @@ -384,7 +401,6 @@ def test_remove_feature_names_pipe_with_skl_transformer_and_df(input_features):
def test_remove_feature_names_pipe_and_skl_transformer_that_adds_features(
input_features,
):
features_in = ["Age", "Marks"]
df = pd.DataFrame({"Age": VARTYPES_DATA["Age"], "Marks": VARTYPES_DATA["Marks"]})

pipe = Pipeline(
Expand Down
Loading