diff --git a/docs/user_guide/creation/CyclicalFeatures.rst b/docs/user_guide/creation/CyclicalFeatures.rst index 59b26567a..5226afe23 100644 --- a/docs/user_guide/creation/CyclicalFeatures.rst +++ b/docs/user_guide/creation/CyclicalFeatures.rst @@ -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 ------------------------------- diff --git a/feature_engine/creation/cyclical_features.py b/feature_engine/creation/cyclical_features.py index 24018b0cd..bcae83299 100644 --- a/feature_engine/creation/cyclical_features.py +++ b/feature_engine/creation/cyclical_features.py @@ -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 ( @@ -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__( @@ -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 @@ -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 diff --git a/tests/test_base_transformers/test_get_feature_names_out_mixin.py b/tests/test_base_transformers/test_get_feature_names_out_mixin.py index 6e5e9bd9c..7315b694b 100644 --- a/tests/test_base_transformers/test_get_feature_names_out_mixin.py +++ b/tests/test_base_transformers/test_get_feature_names_out_mixin.py @@ -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()), ] ) @@ -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)), ] ) @@ -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)), ] ) @@ -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) @@ -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()), ] ) @@ -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( diff --git a/tests/test_creation/test_cyclical_features.py b/tests/test_creation/test_cyclical_features.py index 5bc1df88f..ab834346f 100644 --- a/tests/test_creation/test_cyclical_features.py +++ b/tests/test_creation/test_cyclical_features.py @@ -1,29 +1,33 @@ +import narwhals as nw import pandas as pd +import polars as pl import pytest from numpy import array from feature_engine.creation import CyclicalFeatures +CYCLICAL_DATA = { + "day": [6, 7, 5, 3, 1, 2, 4], + "months": [3, 7, 9, 12, 4, 6, 12], +} -@pytest.fixture -def df_cyclical(): - df = { - "day": [6, 7, 5, 3, 1, 2, 4], - "months": [3, 7, 9, 12, 4, 6, 12], - } - df = pd.DataFrame(df) - return df + +def assert_df_equal(X, expected: dict) -> None: + result = nw.from_native(X, eager_only=True).to_dict(as_series=False) + assert list(result.keys()) == list(expected.keys()) + for col, values in expected.items(): + assert result[col] == pytest.approx(values, abs=1e-5) -def test_general_transformation_without_dropping_variables(df_cyclical): +@pytest.mark.parametrize("make_df", [pd.DataFrame, pl.DataFrame]) +def test_general_transformation_without_dropping_variables(make_df): # test case 1: just one variable. + df = make_df(CYCLICAL_DATA) cyclical = CyclicalFeatures(variables=["day"]) - X = cyclical.fit_transform(df_cyclical) + X = cyclical.fit_transform(df) - transf_df = df_cyclical.copy() - - # expected output - transf_df["day_sin"] = [ + expected = dict(CYCLICAL_DATA) + expected["day_sin"] = [ -0.78183, 0.0, -0.97493, @@ -32,7 +36,7 @@ def test_general_transformation_without_dropping_variables(df_cyclical): 0.97493, -0.43388, ] - transf_df["day_cos"] = [ + expected["day_cos"] = [ 0.623490, 1.0, -0.222521, @@ -46,18 +50,18 @@ def test_general_transformation_without_dropping_variables(df_cyclical): assert cyclical.max_values_ == {"day": 7} # test transform output - pd.testing.assert_frame_equal(X, transf_df) + assert_df_equal(X, expected) -def test_general_transformation_dropping_original_variables(df_cyclical): +@pytest.mark.parametrize("make_df", [pd.DataFrame, pl.DataFrame]) +def test_general_transformation_dropping_original_variables(make_df): # test case 1: just one variable, but dropping the variable after transformation + df = make_df(CYCLICAL_DATA) cyclical = CyclicalFeatures(variables=["day"], drop_original=True) - X = cyclical.fit_transform(df_cyclical) - - transf_df = df_cyclical.copy() + X = cyclical.fit_transform(df) - # expected output - transf_df["day_sin"] = [ + expected = dict(CYCLICAL_DATA) + expected["day_sin"] = [ -0.78183, 0.0, -0.97493, @@ -66,7 +70,7 @@ def test_general_transformation_dropping_original_variables(df_cyclical): 0.97493, -0.43388, ] - transf_df["day_cos"] = [ + expected["day_cos"] = [ 0.623490, 1.0, -0.222521, @@ -75,60 +79,61 @@ def test_general_transformation_dropping_original_variables(df_cyclical): -0.222521, -0.900969, ] - transf_df = transf_df.drop(columns="day") + del expected["day"] # test fit attr assert cyclical.n_features_in_ == 2 assert cyclical.max_values_ == {"day": 7} # test transform output - pd.testing.assert_frame_equal(X, transf_df) + assert_df_equal(X, expected) -def test_automatically_find_variables(df_cyclical): +@pytest.mark.parametrize("make_df", [pd.DataFrame, pl.DataFrame]) +def test_automatically_find_variables(make_df): # test case 2: automatically select variables + df = make_df(CYCLICAL_DATA) cyclical = CyclicalFeatures(variables=None, drop_original=True) - X = cyclical.fit_transform(df_cyclical) - transf_df = df_cyclical.copy() - - # expected output - transf_df["day_sin"] = [ - -0.78183, - 0.0, - -0.97493, - 0.43388, - 0.78183, - 0.97493, - -0.43388, - ] - transf_df["day_cos"] = [ - 0.62349, - 1.0, - -0.222521, - -0.900969, - 0.62349, - -0.222521, - -0.900969, - ] - transf_df["months_sin"] = [ - 1.0, - -0.5, - -1.0, - 0.0, - 0.86603, - 0.0, - 0.0, - ] - transf_df["months_cos"] = [ - 0.0, - -0.86603, - -0.0, - 1.0, - -0.5, - -1.0, - 1.0, - ] - transf_df = transf_df.drop(columns=["day", "months"]) + X = cyclical.fit_transform(df) + + expected = { + "day_sin": [ + -0.78183, + 0.0, + -0.97493, + 0.43388, + 0.78183, + 0.97493, + -0.43388, + ], + "day_cos": [ + 0.62349, + 1.0, + -0.222521, + -0.900969, + 0.62349, + -0.222521, + -0.900969, + ], + "months_sin": [ + 1.0, + -0.5, + -1.0, + 0.0, + 0.86603, + 0.0, + 0.0, + ], + "months_cos": [ + 0.0, + -0.86603, + -0.0, + 1.0, + -0.5, + -1.0, + 1.0, + ], + } # test fit attr assert cyclical.max_values_ == { @@ -137,44 +142,53 @@ def test_automatically_find_variables(df_cyclical): } # test transform output - pd.testing.assert_frame_equal(X, transf_df) + assert_df_equal(X, expected) -def test_fit_raises_error_if_na_in_df(df_na): +@pytest.mark.parametrize("make_df", [pd.DataFrame, pl.DataFrame]) +def test_fit_raises_error_if_na_in_df(make_df): # test case 3: when dataset contains na, fit method - with pytest.raises(ValueError): - transformer = CyclicalFeatures() - transformer.fit(df_na) + df = make_df({"day": [1, 2, None, 4], "months": [1, 2, 3, 4]}) + msg = "Some of the variables in the dataset contain NaN" + with pytest.raises(ValueError, match=msg): + CyclicalFeatures().fit(df) -def test_fit_raises_error_if_user_dictionary_key_not_in_df(df_cyclical): +@pytest.mark.parametrize("make_df", [pd.DataFrame, pl.DataFrame]) +def test_fit_raises_error_if_user_dictionary_key_not_in_df(make_df): + df = make_df(CYCLICAL_DATA) + # message differs by backend (pandas KeyError vs narwhals + # ColumnNotFoundError, a KeyError subclass), so no match= here. with pytest.raises(KeyError): - transformer = CyclicalFeatures(max_values={"dayi": 31}) - transformer.fit(df_cyclical) - + CyclicalFeatures(max_values={"dayi": 31}).fit(df) -def test_raises_error_when_init_parameters_not_permitted(df_cyclical): - with pytest.raises(TypeError): +def test_raises_error_when_init_parameters_not_permitted(): + msg = "The parameter can only take a dictionary or None" + with pytest.raises(TypeError, match=msg): # when max_values is not a dictionary CyclicalFeatures(max_values=("dayi", 31)) - with pytest.raises(ValueError): + msg = "All values in the dictionary must be integer or float" + with pytest.raises(ValueError, match=msg): # when max_values values are not integers or string CyclicalFeatures(max_values={"day": "31"}) - with pytest.raises(ValueError): + msg = "drop_original takes only boolean values True and False" + with pytest.raises(ValueError, match=msg): # when drop original is not a boolean CyclicalFeatures(drop_original="True") -def test_max_values_mapping(df_cyclical): +@pytest.mark.parametrize("make_df", [pd.DataFrame, pl.DataFrame]) +def test_max_values_mapping(make_df): + df = make_df(CYCLICAL_DATA) cyclical = CyclicalFeatures(variables="day", max_values={"day": 31}) - X = cyclical.fit_transform(df_cyclical) + X = cyclical.fit_transform(df) - transf_df = df_cyclical.copy() - transf_df["day_sin"] = [ + expected = dict(CYCLICAL_DATA) + expected["day_sin"] = [ 0.937752, 0.988468, 0.848644, @@ -183,7 +197,7 @@ def test_max_values_mapping(df_cyclical): 0.394355, 0.724792, ] - transf_df["day_cos"] = [ + expected["day_cos"] = [ 0.347305, 0.151428, 0.528964, @@ -192,34 +206,43 @@ def test_max_values_mapping(df_cyclical): 0.918958, 0.688967, ] - pd.testing.assert_frame_equal(X, transf_df) + assert_df_equal(X, expected) +@pytest.mark.parametrize("make_df", [pd.DataFrame, pl.DataFrame]) @pytest.mark.parametrize( "input_features", [None, ["day", "months"], array(["day", "months"])] ) -def test_get_feature_names_out(df_cyclical, input_features): +def test_get_feature_names_out(make_df, input_features): # default features from all variables + df = make_df(CYCLICAL_DATA) transformer = CyclicalFeatures() - X = transformer.fit_transform(df_cyclical) - feat_out = list(df_cyclical.columns) + [ + X = transformer.fit_transform(df) + feat_out = list(CYCLICAL_DATA.keys()) + [ "day_sin", "day_cos", "months_sin", "months_cos", ] - assert list(X.columns) == transformer.get_feature_names_out() + assert ( + list(nw.from_native(X, eager_only=True).columns) + == transformer.get_feature_names_out() + ) assert transformer.get_feature_names_out(input_features=input_features) == feat_out - with pytest.raises(ValueError): + msg = "input_features is not equal to feature_names_in_" + with pytest.raises(ValueError, match=msg): transformer.get_feature_names_out(input_features=["day"]) - with pytest.raises(ValueError): + with pytest.raises(ValueError, match=msg): transformer.get_feature_names_out(input_features=["sandia", "banana"]) transformer = CyclicalFeatures(drop_original=True) - X = transformer.fit_transform(df_cyclical) + X = transformer.fit_transform(df) feat_out = ["day_sin", "day_cos", "months_sin", "months_cos"] - assert list(X.columns) == transformer.get_feature_names_out() + assert ( + list(nw.from_native(X, eager_only=True).columns) + == transformer.get_feature_names_out() + ) assert transformer.get_feature_names_out(input_features=input_features) == feat_out