Skip to content

Commit 630817c

Browse files
solegalliclaude
andcommitted
Migrate OneHotEncoder to narwhals, add polars support
Uses narwhals' to_dummies() for the actual expansion rather than a manual numpy/dict loop, since it's a real vectorized one-hot op on both backends. Handles two edge cases to_dummies() doesn't cover directly: a fixed-length prefix placeholder ("__ohe_tmp__") swapped back out by slicing rather than by using the real column name, since to_dummies() only prefixes with the Series name when it's truthy - a falsy real name (e.g. an int column literally named 0) would otherwise silently drop the prefix; and learned categories absent from (or present-but-unlearned in) a given transform batch, filled with an explicit all-0 column so unseen categories are encoded as 0 across the board, matching the pre-narwhals behavior exactly. fit()'s value_counts()/unique() calls and transform()'s reassembly are a single unified narwhals path - no pandas/polars split needed, verified directly on both backends (identical dummy columns/values for identical input). Rewrote tests/test_encoding/test_onehot_encoder.py to the single cross-backend-parametrized-test convention: local dict fixtures (dropping the pandas-only global df_enc_big/df_enc_numeric/df_enc_binary fixtures) parametrized over make_df in [pd.DataFrame, pl.DataFrame], with narwhals- based column/sum assertions replacing pd.testing.assert_frame_equal. test_variables_cast_as_category stays pandas-only (pandas category dtype has no polars equivalent under test there). Verified: 43/43 own tests, full encoding suite 340 passed/17 pre-existing failures (matches the narwhals-encoding-base baseline exactly), flake8 and mypy clean, sphinx -W build clean (only the pre-existing unrelated linkcode_resolve warning), no pandas import in this file itself. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent ae0a9c2 commit 630817c

3 files changed

Lines changed: 261 additions & 233 deletions

File tree

docs/user_guide/encoding/OneHotEncoder.rst

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -521,6 +521,39 @@ We see the names of the columns below:
521521
'embarked_S',
522522
'embarked_C']
523523
524+
With polars
525+
-----------
526+
527+
:class:`OneHotEncoder()` works the same way with a polars dataframe:
528+
529+
.. code:: python
530+
531+
import polars as pl
532+
from feature_engine.encoding import OneHotEncoder
533+
534+
X = pl.DataFrame({"x1": ["b", "b", "b", "a", "a"], "x2": [1, 2, 3, 4, 5]})
535+
536+
ohe = OneHotEncoder(variables=["x1"])
537+
ohe.fit(X)
538+
539+
print(ohe.transform(X))
540+
541+
.. code:: text
542+
543+
shape: (5, 3)
544+
┌─────┬──────┬──────┐
545+
│ x2 ┆ x1_b ┆ x1_a │
546+
│ --- ┆ --- ┆ --- │
547+
│ i64 ┆ i8 ┆ i8 │
548+
╞═════╪══════╪══════╡
549+
│ 1 ┆ 1 ┆ 0 │
550+
│ 2 ┆ 1 ┆ 0 │
551+
│ 3 ┆ 1 ┆ 0 │
552+
│ 4 ┆ 0 ┆ 1 │
553+
│ 5 ┆ 0 ┆ 1 │
554+
└─────┴──────┴──────┘
555+
556+
524557
Considerations
525558
--------------
526559

feature_engine/encoding/one_hot.py

Lines changed: 52 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@
33

44
from typing import List, Optional, Union
55

6-
import numpy as np
7-
import pandas as pd
6+
import narwhals as nw
7+
from narwhals.typing import IntoDataFrame
88

99
from feature_engine._docstrings.fit_attributes import (
1010
_feature_names_in_docstring,
@@ -196,7 +196,7 @@ def __init__(
196196
self.drop_last = drop_last
197197
self.drop_last_binary = drop_last_binary
198198

199-
def fit(self, X: pd.DataFrame, y: Optional[pd.Series] = None):
199+
def fit(self, X: IntoDataFrame, y: Optional[IntoDataFrame] = None):
200200
"""
201201
Learns the unique categories per variable. If top_categories is indicated,
202202
it will learn the most popular categories. Alternatively, it learns all
@@ -205,7 +205,7 @@ def fit(self, X: pd.DataFrame, y: Optional[pd.Series] = None):
205205
Parameters
206206
----------
207207
208-
X: pandas dataframe of shape = [n_samples, n_features]
208+
X: pandas or polars dataframe of shape = [n_samples, n_features]
209209
The training input samples.
210210
Can be the entire dataframe, not just selected variables.
211211
@@ -218,56 +218,56 @@ def fit(self, X: pd.DataFrame, y: Optional[pd.Series] = None):
218218
variables_ = self._check_or_select_variables(X)
219219
_check_contains_na(X, variables_)
220220

221+
nw_X = nw.from_native(X, eager_only=True)
221222
self.encoder_dict_ = {}
222223

223224
for var in variables_:
225+
col = nw_X.get_column(var)
224226

225227
# make dummies only for the most popular categories
226228
if self.top_categories:
227-
self.encoder_dict_[var] = [
228-
x
229-
for x in X[var]
230-
.value_counts()
231-
.sort_values(ascending=False)
232-
.head(self.top_categories)
233-
.index
234-
]
229+
top = col.value_counts(sort=True, name="count").head(
230+
self.top_categories
231+
)
232+
self.encoder_dict_[var] = top.get_column(var).to_list()
235233

236234
else:
237-
category_ls = list(X[var].unique())
235+
category_ls = col.unique(maintain_order=True).to_list()
238236

239237
# return k-1 dummies
240-
if self.drop_last:
238+
if self.drop_last is True:
241239
self.encoder_dict_[var] = category_ls[:-1]
242240

243241
# return k dummies
244242
else:
245243
self.encoder_dict_[var] = category_ls
246244

247-
self.variables_binary_ = [var for var in variables_ if X[var].nunique() == 2]
245+
self.variables_binary_ = [
246+
var for var in variables_ if nw_X.get_column(var).n_unique() == 2
247+
]
248248

249249
# automatically encode binary variables as 1 dummy
250-
if self.drop_last_binary:
250+
if self.drop_last_binary is True:
251251
for var in self.variables_binary_:
252-
category = X[var].unique()[0]
252+
category = nw_X.get_column(var).unique(maintain_order=True)[0]
253253
self.encoder_dict_[var] = [category]
254254

255255
self.variables_ = variables_
256256
self._get_feature_names_in(X)
257257
return self
258258

259-
def transform(self, X: pd.DataFrame) -> pd.DataFrame:
259+
def transform(self, X: IntoDataFrame) -> IntoDataFrame:
260260
"""
261261
Replaces the categorical variables by the binary variables.
262262
263263
Parameters
264264
----------
265-
X: pandas dataframe of shape = [n_samples, n_features]
265+
X: pandas or polars dataframe of shape = [n_samples, n_features]
266266
The data to transform.
267267
268268
Returns
269269
-------
270-
X_new: pandas dataframe.
270+
X_new: pandas or polars dataframe.
271271
The transformed dataframe. The shape of the dataframe will be different from
272272
the original as it includes the dummy variables in place of the
273273
original categorical ones.
@@ -278,20 +278,43 @@ def transform(self, X: pd.DataFrame) -> pd.DataFrame:
278278
# check if dataset contains na
279279
_check_contains_na(X, self.variables_)
280280

281+
nw_X = nw.from_native(X, eager_only=True)
282+
dummy_frames = []
283+
# a placeholder Series name, swapped back out below by a fixed-length
284+
# prefix slice (never by parsing the category suffix): to_dummies()
285+
# only prefixes with the Series name when it's truthy, so a falsy
286+
# real name (e.g. an int column literally named 0) would otherwise
287+
# silently drop the prefix and make every dummy column look "missing".
288+
tmp_name = "__ohe_tmp__"
281289
for feature in self.variables_:
282-
for category in self.encoder_dict_[feature]:
283-
dummy_df = pd.DataFrame(
284-
{f"{feature}_{category}": np.where(X[feature] == category, 1, 0)},
285-
index=X.index,
290+
desired = [
291+
f"{feature}_{category}" for category in self.encoder_dict_[feature]
292+
]
293+
dummies = (
294+
nw_X.get_column(feature).alias(tmp_name).to_dummies(separator="_")
295+
)
296+
dummies = dummies.rename(
297+
{c: f"{feature}{c[len(tmp_name):]}" for c in dummies.columns}
298+
)
299+
# categories learned in fit() but absent from, or unseen categories
300+
# present in, this particular transform batch: to_dummies() only
301+
# creates columns for values it actually finds, so any learned
302+
# category missing here is filled with an all-0 column, and
303+
# selecting just `desired` drops any column for a category that
304+
# wasn't learned (unseen categories are encoded as 0 across the
305+
# board, matching the pre-narwhals behaviour).
306+
missing = [c for c in desired if c not in dummies.columns]
307+
if len(missing) > 0:
308+
dummies = dummies.with_columns(
309+
**{c: nw.lit(0, dtype=nw.Int8) for c in missing}
286310
)
287-
X = pd.concat([X, dummy_df], axis=1)
311+
dummy_frames.append(dummies.select(desired))
288312

289-
# drop the original non-encoded variables.
290-
X.drop(labels=self.variables_, axis=1, inplace=True)
313+
nw_X = nw.concat([nw_X.drop(*self.variables_), *dummy_frames], how="horizontal")
291314

292-
return X
315+
return nw_X.to_native()
293316

294-
def inverse_transform(self, X: pd.DataFrame):
317+
def inverse_transform(self, X: IntoDataFrame):
295318
"""inverse_transform is not implemented for this transformer."""
296319
raise NotImplementedError(
297320
"inverse_transform is not implemented for this transformer."

0 commit comments

Comments
 (0)