From 90c05b2d3c16366aa84e36a2149a66f6c9710a7b Mon Sep 17 00:00:00 2001 From: Soledad Galli Date: Mon, 24 Aug 2026 17:15:24 +0200 Subject: [PATCH 1/3] Migrate CyclicalFeatures to narwhals, add polars support fit(): unified across backends via .to_numpy().max(axis=0) instead of pandas' .max().to_dict() (~1.55x faster for pandas, ~1.28x for polars, benchmarked). .tolist() keeps the returned dict's values as plain Python int/float, matching the old .to_dict() dtype. transform(): kept as two branches rather than one narwhals-only path - benchmarked running narwhals expressions against a pandas-backed frame and it was consistently 1.24x-2.06x slower than the pandas-native loop across variable counts and row counts, worse at small scale. The pandas branch is therefore left as the original, unmodified loop (an earlier numpy-vectorized version of it was only a 1.0x-1.4x gain, not worth it once the branches stay separate anyway). The narwhals branch uses column expressions, the only approach that stayed competitive with pandas-native as variable count grows (a numpy-array round-trip loses to expressions on polars once there is more than 1 variable). Verified no legacy numpy-array-input code remains in this file or its base classes. Tests rewritten to parametrize pandas and polars via make_df; error-matching tightened per AGENTS.md except where the message legitimately differs by backend. Docstring and user-guide example gained a polars walkthrough per the new AGENTS.md doc-sync rule. --- docs/user_guide/creation/CyclicalFeatures.rst | 54 +++++ feature_engine/creation/cyclical_features.py | 77 +++++-- tests/test_creation/test_cyclical_features.py | 211 ++++++++++-------- 3 files changed, 233 insertions(+), 109 deletions(-) diff --git a/docs/user_guide/creation/CyclicalFeatures.rst b/docs/user_guide/creation/CyclicalFeatures.rst index 59b26567a..7fff72815 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:: python + + 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..6ab3e024c 100644 --- a/feature_engine/creation/cyclical_features.py +++ b/feature_engine/creation/cyclical_features.py @@ -1,7 +1,9 @@ from typing import Dict, List, Optional, Union +import narwhals as nw +import narwhals.dependencies as nwd 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 +124,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 +167,33 @@ 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 nwd.is_pandas_dataframe(X) is True: + max_arr = X[variables_].to_numpy().max(axis=0) + 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 +204,39 @@ 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) - 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) + if nwd.is_pandas_dataframe(X) is True: + 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 is True: + X = X.drop(columns=self.variables_) + else: + new_cols = [] + for variable in self.variables_: + 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_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 From 0234294977d2675f4658c9482bdb1abad64edaf0 Mon Sep 17 00:00:00 2001 From: Soledad Galli Date: Mon, 24 Aug 2026 17:56:05 +0200 Subject: [PATCH 2/3] unify pandas/polars branches --- feature_engine/creation/cyclical_features.py | 45 +++++++------------- 1 file changed, 16 insertions(+), 29 deletions(-) diff --git a/feature_engine/creation/cyclical_features.py b/feature_engine/creation/cyclical_features.py index 6ab3e024c..91448ea27 100644 --- a/feature_engine/creation/cyclical_features.py +++ b/feature_engine/creation/cyclical_features.py @@ -182,18 +182,13 @@ def fit(self, X: IntoDataFrame, y: Optional[IntoSeries] = None): """ if self.max_values is None: X, variables_ = self._fit_setup(X) - if nwd.is_pandas_dataframe(X) is True: - max_arr = X[variables_].to_numpy().max(axis=0) - 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())) + max_arr = ( + nw.from_native(X, eager_only=True) + .select(variables_) + .to_numpy() + .max(axis=0) + ) + max_values_ = dict(zip(variables_, max_arr)) else: X, variables_ = super()._fit_from_dict(X, self.max_values) max_values_ = self.max_values @@ -220,23 +215,15 @@ def transform(self, X: IntoDataFrame) -> IntoDataFrame: """ X = self._check_transform_input_and_state(X) - if nwd.is_pandas_dataframe(X) is True: - 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 is True: - X = X.drop(columns=self.variables_) - else: - new_cols = [] - for variable in self.variables_: - 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() + new_cols = [] + for variable in self.variables_: + 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 From 5a835ea7febfd027950906058750721ded5f845d Mon Sep 17 00:00:00 2001 From: Soledad Galli Date: Mon, 24 Aug 2026 18:14:01 +0200 Subject: [PATCH 3/3] Fix style/docs failures on top of the pandas/polars branch unification Style: removed the now-unused narwhals.dependencies import (flake8 F401) left over from dropping the is_pandas_dataframe branch. Also fixed 7 pre-existing flake8 issues (line length, unused variable) in test_get_feature_names_out_mixin.py that predate this branch. Docs: docs/user_guide/creation/CyclicalFeatures.rst's polars output block was under `.. code:: python`, and Sphinx's Pygments highlighter can't lex the box-drawing table as Python (misc.highlighting_failure), which -W promotes to a build error. Switched to `.. code:: text`, matching the convention already used elsewhere (PowerTransformer.rst, MeanImputer.rst) for output-only blocks. Pre-existing bug in my own doc addition, unrelated to the branch unification. Two correctness issues surfaced by testing the unification: - max_values_ lost its .tolist() call, so it held numpy scalars (np.int64) instead of plain Python int/float - restored. - narwhals' .select([]) collapses row count to 0 (not just columns), so routing pandas through the narwhals numpy path broke return_empty=True (empty variables_) with a "zero-size array to reduction operation maximum" error. Guarded for it explicitly, since return_empty=True is a real, designed-for case, not a hypothetical. --- docs/user_guide/creation/CyclicalFeatures.rst | 2 +- feature_engine/creation/cyclical_features.py | 23 +++++++++----- .../test_get_feature_names_out_mixin.py | 30 ++++++++++++++----- 3 files changed, 39 insertions(+), 16 deletions(-) diff --git a/docs/user_guide/creation/CyclicalFeatures.rst b/docs/user_guide/creation/CyclicalFeatures.rst index 7fff72815..5226afe23 100644 --- a/docs/user_guide/creation/CyclicalFeatures.rst +++ b/docs/user_guide/creation/CyclicalFeatures.rst @@ -241,7 +241,7 @@ And the transformed dataframe contains the same cyclical features: print(X) -.. code:: python +.. code:: text shape: (7, 6) ┌─────┬────────┬─────────────┬───────────┬─────────────┬─────────────┐ diff --git a/feature_engine/creation/cyclical_features.py b/feature_engine/creation/cyclical_features.py index 91448ea27..bcae83299 100644 --- a/feature_engine/creation/cyclical_features.py +++ b/feature_engine/creation/cyclical_features.py @@ -1,7 +1,6 @@ from typing import Dict, List, Optional, Union import narwhals as nw -import narwhals.dependencies as nwd import numpy as np from narwhals.typing import IntoDataFrame, IntoSeries @@ -182,13 +181,21 @@ def fit(self, X: IntoDataFrame, y: Optional[IntoSeries] = None): """ if self.max_values is None: X, variables_ = self._fit_setup(X) - max_arr = ( - nw.from_native(X, eager_only=True) - .select(variables_) - .to_numpy() - .max(axis=0) - ) - max_values_ = dict(zip(variables_, max_arr)) + 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 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(