Skip to content

Migrate RareLabelEncoder to narwhals, add polars support - #1030

Open
solegalli wants to merge 2 commits into
narwhals-migrationfrom
narwhals-rare-label-encoder
Open

Migrate RareLabelEncoder to narwhals, add polars support#1030
solegalli wants to merge 2 commits into
narwhals-migrationfrom
narwhals-rare-label-encoder

Conversation

@solegalli

Copy link
Copy Markdown
Collaborator

Migrates RareLabelEncoder to narwhals with polars support.

fit(): pandas .unique() / .value_counts(normalize=True) replaced with narwhals Series.n_unique() (cardinality check — matches pandas' plain unique() length, which counts null as its own category) and drop_nulls().value_counts(sort=True, normalize=True) (same drop_nulls()/sort=True reasoning as CountEncoder.fit()).

transform() doesn't reuse CategoricalMethodsMixin._encode() (that's a dict-based numeric remap; this encoder keeps frequent categories as-is and only replaces the rest). Rewritten from pandas' .loc[~isin(...), feature] = replace_with onto nw.when(<Series>).then(<Series>).otherwise(nw.lit(replace_with)). Series from get_column() (not nw.col()) keeps this working for pandas integer column names. A pandas Categorical column still needs add_categories(replace_with) first — kept as a small is_pandas-gated block (structural, like the base's existing reorder branches); polars has no matching restriction.

Merge vs split: first pass (zip_with + full replacement Series) averaged 2.41x pandas-native at 50k–100k rows, mostly the cost of building a full-length Series every transform(). Switched to nw.when(keep).then(col).otherwise(nw.lit(replace_with)) so the backend broadcasts the scalar — dropped to 1.60x average, 1.12x–1.54x at 100k/10 cols. narwhals-on-polars faster than pandas-native throughout. Single narwhals path, no performance split — remaining overhead is fixed per-call cost, a few ms at most.

Tests rewritten to one parametrized test per behaviour over make_df in [pd.DataFrame, pl.DataFrame] (39, up from 29). Integer-column-name and category-dtype tests kept pandas-only. test_max_n_categories_with_numeric_var split into a pandas-only version and a new polars-only version documenting the real expected difference (polars can't hold mixed int/str in one column, so a numeric var + string replace_with casts the whole column to string).

Verified: test_rare_label_encoder.py 39 passed; full tests/test_encoding 336 passed / 17 pre-existing failures with identical IDs to the baseline. flake8 / mypy clean, sphinx -W clean. RareLabelEncoder.rst examples verified against actual output (also fixed an unrelated pre-existing value_counts() Series-name drift while there); "With polars" section added. Titanic section left untouched (SSL blocks urllib in sandbox; workaround showed matching encoder_dict_/transform output).


Stacked on #999 (narwhals-encoding-base). Until that merges this PR's diff also contains the shared CategoricalMethodsMixin commit; review #999 first.

solegalli and others added 2 commits August 31, 2026 00:41
fit() replaces pandas .unique()/.value_counts(normalize=True) with
narwhals Series.n_unique() (for the cardinality check - matches pandas'
plain unique() length, which counts a null as its own category, unlike
pandas' nunique() which drops it) and drop_nulls().value_counts(sort=True,
normalize=True) (same drop_nulls()/sort=True reasoning as
CountEncoder.fit(): narwhals' value_counts() has no dropna param and
narwhals' own value_counts default is unsorted).

transform() doesn't reuse CategoricalMethodsMixin._encode() (that's a
dict-based numeric remap; this encoder keeps frequent categories as-is
and only replaces the rest), so it's rewritten from pandas'
.loc[~isin(...), feature] = replace_with onto
nw.when(<Series>).then(<Series>).otherwise(nw.lit(replace_with)).alias(
feature). Passing Series from get_column() (not nw.col()) into
when/then/otherwise keeps this working for pandas integer column names,
same as base_encoder.py's precedent. A pandas Categorical column still
needs its own add_categories(replace_with) step before assignment - kept
as a small is_pandas-gated block (structural, like base_encoder.py's
existing reorder branches), since narwhals has no cross-backend
equivalent and polars has no matching restriction. Unlike the old
pandas-only code, no manual object-dtype fixup is needed before
assignment for the ignore_format + numeric-variable + string
replace_with case: narwhals resolves the common dtype itself (object in
pandas, cast-to-string in polars).

Benchmarked pandas-native vs narwhals-on-pandas vs narwhals-on-polars at
10k/50k/100k rows x 1/2/10 columns x 5/50 categories, warmed up. First
pass (zip_with(col, new_series_filled_with_replace_with)) averaged
2.41x pandas-native at 50k-100k rows - most of that cost was
constructing a full same-length replacement Series every transform()
call (~2.5ms of a ~4.8ms transform at 100k rows, confirmed by isolating
just the Series construction). Switched to nw.when(keep).then(col)
.otherwise(nw.lit(replace_with)), which lets the backend broadcast the
scalar instead of materialising a parallel array: dropped the average
to 1.60x, converging to 1.12x-1.54x at 100k rows/10 columns, the
"realistic size" range. narwhals-on-polars is faster than pandas-native
throughout (0.7x-1.5x, mostly <1x at 50k+ rows). Merged into a single
narwhals path per the established decision rule - no pandas/polars
performance split - the remaining overhead is fixed per-call cost, not
scaling cost, and stays under a few ms in absolute terms even at the
largest sizes tested.

Rewrote test_rare_label_encoder.py to one parametrized test per
behaviour over @pytest.mark.parametrize("make_df", [pd.DataFrame,
pl.DataFrame]), replacing the shared pandas-only module-level fixtures
(df_enc_big, df_enc_big_na, df_enc_numeric, from tests/conftest.py,
still used by other encoder test files) with local dict constants both
backends can build from, per the CountEncoder precedent. Kept
test_when_varnames_are_numbers and the three category-dtype tests
pandas-only (integer column names and pandas Categorical dtype are
backend-specific per AGENTS.md). Split
test_max_n_categories_with_numeric_var into a pandas-only version (the
existing str()-workaround test, unchanged) plus a new polars-only
version documenting the real, expected behavioural difference: polars
can't hold mixed int/str values in one column the way pandas' object
dtype does, so a numeric variable with a string replace_with casts the
whole column to string instead of leaving frequent numeric categories
as numbers.

Verified: tests/test_encoding/test_rare_label_encoder.py - 39 passed
(up from 29, from parametrizing over both backends); full
tests/test_encoding suite - 336 passed, 17 pre-existing failures with
identical test IDs confirmed against the unmodified base_encoder.py
baseline (numpy-array-input rejection checks plus 3 MeanEncoder
inverse_transform failures from mean_encoding.py's still-unmigrated
fit() - predate this change, reproduced identically on the unmodified
rare_label.py too). flake8 clean on feature_engine and tests. mypy
clean. Module imports with pandas blocked (loaded standalone, same
technique as the base_encoder.py migration, since sibling encoder files
in this package still import pandas at module level). sphinx -W build
clean (only the pre-existing linkcode_resolve warning, confirmed
identical against the unmodified baseline). Verified every doc example
in RareLabelEncoder.rst against actual output; fixed a pre-existing,
unrelated value_counts() Series-name drift ("Name: var_A" ->
"Name: count", a pandas version difference, not caused by this
migration) while touching that page, and added a verified "With
polars" section to both the class docstring and the user guide (the
polars value_counts() example needed an explicit .sort() - unlike
pandas, its groupby-based value_counts() order isn't stable run to
run). The Titanic-dataset section of the user guide could not be
re-verified against live output in this sandboxed environment (SSL
cert verification blocks urllib by default here, though curl succeeds)
and was left untouched; a workaround (unverified SSL context) showed
matching encoder_dict_/transform output, with only an unrelated
.unique() repr-formatting difference from a newer pandas version.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Bind check_X / _check_transform_input_and_state results to nw_X and keep
the original native X for _check_or_select_variables, _check_na and
_check_contains_na (those helpers still expect native input, matching the
CategoricalImputer migration on narwhals-migration). Drop the redundant
nw.from_native(X) round-trips in fit() and transform(). In transform(),
detect the pandas Categorical fix-up path via nw_X.implementation
.is_pandas() and run it on a copy so the user's dataframe is not mutated.
Drop the now-unused narwhals.dependencies import.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@solegalli
solegalli force-pushed the narwhals-rare-label-encoder branch from 0365e7e to b6eaf5c Compare August 30, 2026 22:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant