Skip to content

Commit ae0a9c2

Browse files
solegalliclaude
andcommitted
Migrate CategoricalMethodsMixin (encoding base) to narwhals, add polars support
Shared base for all 8 encoders. _get_feature_names_in() and _check_transform_input_and_state() follow the same is_pandas-gated column-reorder pattern as BaseImputer/DecisionTreeFeatures. _check_or_select_variables() needed no change: the variable_handling helpers it calls are already fully narwhals-generic. The hot path is _encode()/inverse_transform(), a per-column dict-based map applied on every transform() call across every encoder. Benchmarked pandas-native .map(dict) vs narwhals Series.replace_strict(dict, default=...) at 10k/50k/100k rows x 1/2/10 columns x 5/50 categories (warmed up first to remove first-call JIT/import overhead): narwhals-on-pandas lands at ~1.06x-1.2x of pandas-native at realistic sizes (50k-100k rows), i.e. minimal loss - merged into a single narwhals path per the established decision rule, no pandas fast-path split. narwhals-on- polars is consistently ~4-5x faster than pandas-native at 100k rows. replace_strict() also *simplifies* the old logic: pandas' plain .map() leaves category-dtype columns as category dtype after mapping, which the old code corrected with a manual "cast to int if all-int else float" step. Verified narwhals' replace_strict resolves straight to a plain numeric dtype on both a pandas category column and a polars Categorical column, so that dtype fixup is dead code once replace_strict replaces .map() - dropped it entirely rather than porting it. Used Series.get_column().replace_strict() (not nw.col(), which only accepts string names) throughout, same as DecisionTreeFeatures' precedent for pandas integer column names - nw.col(feature) blew up on int-named columns (caught by the existing test_column_names_are_numbers test, which polars can't cover since it has no integer-column-name concept). _check_nan_values_after_transformation() rewritten off pandas' .isnull().sum().sum()/.columns[...] chain onto per-column Series.null_count(), for the same int-column-name reason. Verified: tests/test_encoding full suite unchanged (17 pre-existing failures - numpy-array-input rejection per the narwhals check_X() contract, plus 3 MeanEncoder inverse_transform failures caused by a pre-existing bug in mean_encoding.py's still-unmigrated fit() passing a numpy y into y.groupby(); reproduced identically against the unmodified base_encoder.py to confirm neither predates nor is introduced by this change - 326 passed both before and after, same failing test IDs). flake8 and mypy clean on the file. Module imports with pandas blocked (loaded standalone, since sibling encoder files in this package are not yet migrated and still import pandas at their own module level). sphinx -W build clean (only the pre-existing unrelated linkcode_resolve warning). Manually verified CountEncoder end-to-end on polars input (fit still pandas-only until its own migration, transform/inverse_transform now backend-agnostic via this mixin) produces identical values to the pandas path, including a pre-existing quirk where count-encoding inverse_transform is ambiguous for categories that share a count (confirmed identical, not a regression, on the old code too). _helper_functions.py checked: pure-python parameter validation, no dataframe interaction, no pandas import - left untouched. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent ed91d48 commit ae0a9c2

1 file changed

Lines changed: 69 additions & 46 deletions

File tree

feature_engine/encoding/base_encoder.py

Lines changed: 69 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import warnings
22
from typing import List, Union
33

4-
import pandas as pd
4+
import narwhals as nw
5+
import narwhals.dependencies as nwd
6+
from narwhals.typing import IntoDataFrame
57
from sklearn.base import BaseEstimator, TransformerMixin
68
from sklearn.utils.validation import check_is_fitted
79

@@ -121,19 +123,19 @@ class CategoricalMethodsMixin(TransformerMixin, BaseEstimator, GetFeatureNamesOu
121123
- GetFeatureNamesOutMixin brings method get_feature_names_out().
122124
"""
123125

124-
def _check_na(self, X: pd.DataFrame, variables):
126+
def _check_na(self, X: IntoDataFrame, variables):
125127
if self.missing_values == "raise":
126128
_check_contains_na(X, variables, error_msg="optional")
127129

128-
def _check_or_select_variables(self, X: pd.DataFrame):
130+
def _check_or_select_variables(self, X: IntoDataFrame):
129131
"""
130132
Finds categorical variables, or alternatively checks that the variables
131133
entered by the user are of type object (categorical).
132134
Checks absence of NA.
133135
134136
Parameters
135137
----------
136-
X: Pandas DataFrame
138+
X: dataframe
137139
138140
Raises
139141
------
@@ -159,37 +161,41 @@ def _check_or_select_variables(self, X: pd.DataFrame):
159161

160162
return variables_
161163

162-
def _get_feature_names_in(self, X: pd.DataFrame):
164+
def _get_feature_names_in(self, X: IntoDataFrame):
163165
"""
164166
Returns attributes `featrure_names_in_` and `n_feature_names_in_`, which are
165167
standard for all transformers in the library.
166168
"""
167169
# save input features
168-
self.feature_names_in_ = X.columns.tolist()
170+
is_pandas = nwd.is_pandas_dataframe(X)
171+
if is_pandas is True:
172+
self.feature_names_in_ = list(X.columns)
173+
else:
174+
self.feature_names_in_ = nw.from_native(X, eager_only=True).columns
169175

170176
# save train set shape
171177
self.n_features_in_ = X.shape[1]
172178

173-
def _check_transform_input_and_state(self, X: pd.DataFrame) -> pd.DataFrame:
179+
def _check_transform_input_and_state(self, X: IntoDataFrame) -> IntoDataFrame:
174180
"""
175181
Checks that the input is a dataframe and of the same size than the one used
176182
in the fit method. Checks absence of NA.
177183
178184
Parameters
179185
----------
180-
X: Pandas DataFrame
186+
X: dataframe
181187
182188
Raises
183189
------
184190
TypeError
185-
If the input is not a Pandas DataFrame
191+
If the input is not a dataframe
186192
ValueError
187193
- If the variable(s) contain null values.
188194
- If the df has different number of features than the df used in fit()
189195
190196
Returns
191197
-------
192-
X: Pandas DataFrame
198+
X: dataframe
193199
The same dataframe entered by the user.
194200
"""
195201

@@ -203,21 +209,29 @@ def _check_transform_input_and_state(self, X: pd.DataFrame) -> pd.DataFrame:
203209
_check_X_matches_training_df(X, self.n_features_in_)
204210

205211
# reorder df to match train set
206-
X = X[self.feature_names_in_]
212+
is_pandas = nwd.is_pandas_dataframe(X)
213+
if is_pandas is True:
214+
X = X[self.feature_names_in_]
215+
else:
216+
X = (
217+
nw.from_native(X, eager_only=True)
218+
.select(self.feature_names_in_)
219+
.to_native()
220+
)
207221

208222
return X
209223

210-
def transform(self, X: pd.DataFrame) -> pd.DataFrame:
224+
def transform(self, X: IntoDataFrame) -> IntoDataFrame:
211225
"""Replace categories with the learned parameters.
212226
213227
Parameters
214228
----------
215-
X: pandas dataframe of shape = [n_samples, n_features].
229+
X: dataframe of shape = [n_samples, n_features].
216230
The dataset to transform.
217231
218232
Returns
219233
-------
220-
X_new: pandas dataframe of shape = [n_samples, n_features].
234+
X_new: dataframe of shape = [n_samples, n_features].
221235
The dataframe containing the categories replaced by numbers.
222236
"""
223237

@@ -231,22 +245,25 @@ def transform(self, X: pd.DataFrame) -> pd.DataFrame:
231245

232246
return X
233247

234-
def _encode(self, X: pd.DataFrame) -> pd.DataFrame:
235-
# replace categories by the learned parameters
236-
for feature in self.encoder_dict_.keys():
237-
X[feature] = X[feature].map(self.encoder_dict_[feature])
238-
239-
# if original variables are cast as categorical, they will remain
240-
# categorical after the encoding, and this is probably not desired
241-
if X[feature].dtype.name == "category":
242-
if all(isinstance(x, int) for x in X[feature]):
243-
X[feature] = X[feature].astype("int")
244-
else:
245-
X[feature] = X[feature].astype("float")
246-
247-
if self.unseen == "encode":
248-
X[self.variables_] = X[self.variables_].fillna(self._unseen)
249-
else:
248+
def _encode(self, X: IntoDataFrame) -> IntoDataFrame:
249+
# replace categories by the learned parameters.
250+
# narwhals' replace_strict() lets one expression both map known
251+
# categories and fill unseen/missing ones via `default`, so the
252+
# pandas-only category-dtype fixup this used to need (map() leaves
253+
# category dtype behind) is no longer necessary: replace_strict
254+
# already resolves to a plain numeric dtype on both backends.
255+
# get_column()/Series.replace_strict() (rather than nw.col(), which
256+
# only accepts string names) is what lets this handle pandas
257+
# integer column names too, same as DecisionTreeFeatures.
258+
default = self._unseen if self.unseen == "encode" else None
259+
nw_X = nw.from_native(X, eager_only=True)
260+
new_series = [
261+
nw_X.get_column(feature).replace_strict(mapping, default=default)
262+
for feature, mapping in self.encoder_dict_.items()
263+
]
264+
X = nw_X.with_columns(*new_series).to_native()
265+
266+
if self.unseen != "encode":
250267
# check if nan values were introduced by the transformation
251268
self._check_nan_values_after_transformation(X)
252269

@@ -255,19 +272,19 @@ def _encode(self, X: pd.DataFrame) -> pd.DataFrame:
255272
def _check_nan_values_after_transformation(self, X):
256273

257274
# check if NaN values were introduced by the encoding
258-
if X[self.variables_].isnull().sum().sum() > 0:
275+
nw_X = nw.from_native(X, eager_only=True)
276+
nan_columns = [
277+
feature
278+
for feature in self.encoder_dict_.keys()
279+
if nw_X.get_column(feature).null_count() > 0
280+
]
259281

260-
# obtain the name(s) of the columns have null values
261-
nan_columns = (
262-
X[self.encoder_dict_.keys()]
263-
.columns[X[self.encoder_dict_.keys()].isnull().any()]
264-
.tolist()
265-
)
282+
if len(nan_columns) > 0:
266283

267284
if len(nan_columns) > 1:
268-
nan_columns_str = ", ".join(nan_columns)
285+
nan_columns_str = ", ".join(str(col) for col in nan_columns)
269286
else:
270-
nan_columns_str = nan_columns[0]
287+
nan_columns_str = str(nan_columns[0])
271288

272289
if self.unseen == "ignore":
273290
warnings.warn(
@@ -280,27 +297,33 @@ def _check_nan_values_after_transformation(self, X):
280297
f"{nan_columns_str}."
281298
)
282299

283-
def inverse_transform(self, X: pd.DataFrame) -> pd.DataFrame:
300+
def inverse_transform(self, X: IntoDataFrame) -> IntoDataFrame:
284301
"""Convert the encoded variable back to the original values.
285302
286303
Parameters
287304
----------
288-
X: pandas dataframe of shape = [n_samples, n_features].
305+
X: dataframe of shape = [n_samples, n_features].
289306
The transformed dataframe.
290307
291308
Returns
292309
-------
293-
X_tr: pandas dataframe of shape = [n_samples, n_features].
310+
X_tr: dataframe of shape = [n_samples, n_features].
294311
The un-transformed dataframe, with the categorical variables containing the
295312
original values.
296313
"""
297314

298315
X = self._check_transform_input_and_state(X)
299316

300-
# replace encoded categories by the original values
301-
for feature in self.encoder_dict_.keys():
302-
inv_map = {v: k for k, v in self.encoder_dict_[feature].items()}
303-
X[feature] = X[feature].map(inv_map)
317+
# replace encoded categories by the original values. get_column()
318+
# rather than nw.col() again, to support pandas integer column names.
319+
nw_X = nw.from_native(X, eager_only=True)
320+
new_series = [
321+
nw_X.get_column(feature).replace_strict(
322+
{v: k for k, v in mapping.items()}, default=None
323+
)
324+
for feature, mapping in self.encoder_dict_.items()
325+
]
326+
X = nw_X.with_columns(*new_series).to_native()
304327

305328
return X
306329

0 commit comments

Comments
 (0)