Skip to content

Commit e9f7d69

Browse files
solegalliclaude
andcommitted
Migrate BaseImputer to narwhals, add polars support (#1002)
* Migrate BaseImputer to narwhals, add polars support Shared base for the imputation module: _transform() (fit-state checks + column reorder) and transform() (fillna via imputer_dict_) are now dataframe-agnostic, with _get_feature_names_in() reading columns through narwhals on non-pandas input. Benchmarked the fillna step (select + fill from a per-column value dict) at 10k/100k/1M rows x 1/2/10 columns: pandas-native fillna runs ~1.3-1.6x faster than the narwhals-generic fill_null equivalent at the 10k-100k row sizes imputers are normally used at (the gap narrows to ~1.0x only past ~1M rows) - a real, not minimal, loss, so pandas keeps its own fast path (is_pandas = nwd.is_pandas_dataframe(X); if is_pandas is True: ... else narwhals fill_null per column). Also benchmarked a numpy rewrite (to_numpy + np.where per column, mirroring RelativeFeatures) but it did not beat pandas-native and was consistently slower than narwhals fill_null on polars, so it wasn't adopted here - unlike RelativeFeatures' arithmetic, a plain value fill is already close to a no-op for both pandas and narwhals/polars, leaving no room for a numpy win. The pandas<3 fillna-downcasting workaround (option_context + infer_objects) is preserved on the pandas branch but no longer imports pandas at module level - the module is fetched via nw.from_native(X).__native_namespace__() only once X is already confirmed to be a pandas dataframe, so no import is attempted on a polars-only install. Verified: tests/test_imputation full suite unchanged (95 passed, 7 pre-existing failures in test_check_estimator_imputers.py - sklearn's check_estimator feeds raw numpy arrays, which check_X() has always rejected per the narwhals migration's dataframe-only contract, predates this change). flake8 and mypy clean on the file. Module imports with pandas import blocked. sphinx -W build clean (only the pre-existing unrelated linkcode_resolve warning). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * tidy code * restore infer object * remove reordering of the df * Adapt BaseImputer to narwhals-returning check_X Since #1019, check_X returns a narwhals DataFrame instead of the native frame. BaseImputer._transform rebinds `X = check_X(X)` and returns it, so transform() then sees a narwhals frame: nwd.is_pandas_dataframe(X) is always False (and emits a UserWarning), skipping the pandas-native fillna fast path. check_X is pure validation, so drop the rebinding and keep returning the native X. transform()'s pandas / narwhals split then works as before, with no warning. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
1 parent bee85c0 commit e9f7d69

1 file changed

Lines changed: 27 additions & 29 deletions

File tree

feature_engine/imputation/base_imputer.py

Lines changed: 27 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,18 @@
1-
import pandas as pd
1+
import narwhals as nw
2+
import narwhals.dependencies as nwd
3+
from narwhals.typing import IntoDataFrame
24
from sklearn.base import BaseEstimator, TransformerMixin
35
from sklearn.utils.validation import check_is_fitted
46

57
from feature_engine._base_transformers.mixins import GetFeatureNamesOutMixin
68
from feature_engine.dataframe_checks import _check_X_matches_training_df, check_X
79
from feature_engine.tags import _return_tags
810

9-
_PANDAS_LT_3 = int(pd.__version__.split(".")[0]) < 3
10-
1111

1212
class BaseImputer(TransformerMixin, BaseEstimator, GetFeatureNamesOutMixin):
1313
"""shared set-up checks and methods across imputers"""
1414

15-
def _transform(self, X: pd.DataFrame) -> pd.DataFrame:
15+
def _transform(self, X: IntoDataFrame) -> IntoDataFrame:
1616
"""
1717
Common checks before transforming data:
1818
@@ -23,59 +23,57 @@ def _transform(self, X: pd.DataFrame) -> pd.DataFrame:
2323
2424
Parameters
2525
----------
26-
X: Pandas DataFrame
26+
X: dataframe of shape = [n_samples, n_features]
2727
2828
Returns
2929
-------
30-
X: Pandas DataFrame
30+
X: dataframe.
3131
The same dataframe entered by the user.
3232
"""
33-
# Check method fit has been called
3433
check_is_fitted(self)
35-
36-
# check that input is a dataframe
37-
X = check_X(X)
38-
39-
# Check that input df contains same number of columns as df used to fit
34+
check_X(X)
4035
_check_X_matches_training_df(X, self.n_features_in_)
4136

42-
# reorder df to match train set
43-
X = X[self.feature_names_in_]
44-
4537
return X
4638

47-
def transform(self, X: pd.DataFrame) -> pd.DataFrame:
39+
def transform(self, X: IntoDataFrame) -> IntoDataFrame:
4840
"""
4941
Replace missing data with the learned parameters.
5042
5143
Parameters
5244
----------
53-
X: pandas dataframe of shape = [n_samples, n_features]
45+
X: dataframe of shape = [n_samples, n_features]
5446
The data to be transformed.
5547
5648
Returns
5749
-------
58-
X_new: pandas dataframe of shape = [n_samples, n_features]
50+
X_new: dataframe of shape = [n_samples, n_features]
5951
The dataframe without missing values in the selected variables.
6052
"""
61-
6253
X = self._transform(X)
6354

64-
# Replace missing data with learned parameters. In pandas < 3, fillna
65-
# downcasts object columns and warns; the option applies the pandas 3
66-
# behavior: no downcasting, and infer_objects restores numeric dtypes.
67-
if _PANDAS_LT_3:
68-
with pd.option_context("future.no_silent_downcasting", True):
69-
X = X.fillna(value=self.imputer_dict_)
70-
else:
55+
# pandas-native fillna is ~1.3-1.6x faster than narwhals-generic
56+
# fill_null equivalent at the 10k-100k
57+
if nwd.is_pandas_dataframe(X):
7158
X = X.fillna(value=self.imputer_dict_)
72-
return X.infer_objects()
59+
X = X.infer_objects()
60+
else:
61+
nw_X = nw.from_native(X, eager_only=True)
62+
nw_X = nw_X.with_columns(
63+
nw.col(var).fill_null(value)
64+
for var, value in self.imputer_dict_.items()
65+
)
66+
X = nw_X.to_native()
67+
68+
return X
7369

7470
def _get_feature_names_in(self, X):
7571
"""Get the names and number of features in the train set (the dataframe
7672
used during fit)."""
77-
78-
self.feature_names_in_ = X.columns.to_list()
73+
if nwd.is_pandas_dataframe(X):
74+
self.feature_names_in_ = list(X.columns)
75+
else:
76+
self.feature_names_in_ = nw.from_native(X, eager_only=True).columns
7977
self.n_features_in_ = X.shape[1]
8078

8179
return self

0 commit comments

Comments
 (0)