Skip to content

Migrate MeanEncoder.fit() to narwhals, add polars support - #1027

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

Migrate MeanEncoder.fit() to narwhals, add polars support#1027
solegalli wants to merge 2 commits into
narwhals-migrationfrom
narwhals-mean-encoder

Conversation

@solegalli

Copy link
Copy Markdown
Collaborator

Migrates MeanEncoder.fit() to narwhals with polars support. transform() / inverse_transform() already come dataframe-agnostic from CategoricalMethodsMixin.

fit() computes, per variable, the mean of y per category (and, with smoothing="auto", the target variance per category), blended with the overall target mean.

Merge vs split: benchmarked a pure-narwhals fit() (group_by/agg) vs pandas-native (10k–100k rows × 1–10 cols × 5–50 categories). narwhals-on-pandas ran ~1.5x–2.9x slower, worst at the most common shape (1–2 cols, 50k–100k rows), crossing the ~1.7x real-loss threshold; narwhals-on-polars competitive to faster. So this splits on is_pandas = nwd.is_pandas_dataframe(X): pandas keeps a close variant of its value_counts/groupby code, polars/other backends go through group_by()/agg(). No cross-backend tie-break fix needed here — encoder_dict_ is a category→mean dict, not a rank-ordered list (verified identical dicts pandas vs polars across smoothing=0.0/100/"auto" and all three unseen settings).

Bug fixed (pre-existing, confirmed against the unmodified file): the old fit() always called y.groupby(X[var]), raising AttributeError when y is a numpy array (list/array-like y, as sklearn's check_X_y produces) — same bug the OrdinalEncoder sibling found. Fixed on the pandas branch by pairing X[var] with y via .assign() when y isn't a Series (positional alignment, matching how a Series y aligned by index), and on the narwhals branch via nw.new_series. Fixes test_inverse_transform_when_* (3) and test_encoders_when_x_pandas_y_numpy[encoder1].

Tests: every test in test_mean_encoder.py rewritten as one make_df in [pd.DataFrame, pl.DataFrame] case per behaviour (41, up from 20), y passed as a plain list in most so the numpy-y fix is exercised throughout. test_variables_cast_as_category stays pandas-only.

Verified: test_mean_encoder.py 41 passed; full tests/test_encoding 345 passed / 13 failed — same IDs as the unmodified base minus the 4 fixed here. flake8 / mypy clean, sphinx -W clean. Docstring + MeanEncoder.rst non-Titanic examples verified against real output, "With polars" section added (Titanic examples untouched — no network in sandbox). Downstream _prediction/base_predictor.py and target_mean_selection.py test suites: 66 passed.


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

fit() computes, per variable, the mean of y per category (and, with
smoothing="auto", the target variance per category), blended with the
overall target mean via a weight that increases with category count.
transform() and inverse_transform() already came dataframe-agnostic
for free from CategoricalMethodsMixin (base_encoder.py, merged
separately).

Benchmarked a pure-narwhals fit() (group_by/agg for count+mean(+var))
against pandas-native (value_counts + groupby) at 10k-100k rows x
1-10 cols x 5-50 categories: narwhals-on-pandas ran ~1.5x-2.9x slower,
worst at the most common shape (1-2 columns, 50k-100k rows), crossing
the ~1.7x real-loss threshold; narwhals-on-polars was competitive to
faster than pandas-native throughout. Per the benchmark-driven
merge-vs-split rule, and matching what the OrdinalEncoder sibling
migration found for the same y-groupby-by-category shape of fit(),
this splits on `is_pandas = nwd.is_pandas_dataframe(X)`: pandas keeps
a close variant of its original value_counts/groupby code, while
polars (and other narwhals backends) goes through group_by()/agg().

Bug fixed (pre-existing, confirmed against the unmodified file): the
old fit() always called `y.groupby(X[var])`, which raises
AttributeError whenever y is a numpy array rather than a Series -
e.g. list/array-like y input, which sklearn's check_X_y machinery
converts to numpy. This is the exact same bug the OrdinalEncoder
sibling found and fixed in its own fit(). Confirmed failing against
the unmodified file (tests/test_encoding/test_mean_encoder.py::
test_inverse_transform_when_no_unseen, ::test_inverse_transform_when_
ignore_unseen, ::test_inverse_transform_when_encode_unseen, plus
test_check_estimator_encoders.py::test_encoders_when_x_pandas_y_numpy
[encoder1] for MeanEncoder) and now passing. Fixed on the pandas
branch by pairing X[var] with y via `.assign()` when y isn't a Series
(aligns a numpy y positionally, matching how `y.groupby(X[var])`
aligned a Series y by index), and on the narwhals branch via
`nw.new_series` for a numpy y. Unlike OrdinalEncoder, no cross-backend
tie-break fix was needed: MeanEncoder's encoder_dict_ is a
category-to-target-mean mapping (a dict), not a rank-ordered list, so
backend-dependent group order doesn't affect the result - verified
pandas and polars produce identical dicts across smoothing=0.0/100/
"auto" and all three `unseen` settings.

Rewrote every test in test_mean_encoder.py as one
@pytest.mark.parametrize("make_df", [pd.DataFrame, pl.DataFrame]) case
per behavior (41 tests, up from 20), using a narwhals-based, NaN-aware
comparison helper; y is passed as a plain list in most tests, which
also exercises the numpy-y bug fix on every parametrized case.
test_variables_cast_as_category stays pandas-only - it exercises
pandas Categorical dtype, which polars has no direct equivalent for.

Verified: tests/test_encoding/test_mean_encoder.py 41 passed (was 20,
3 failing). tests/test_encoding full suite: 345 passed, 13 failed -
same failing test IDs as the unmodified base minus the 4 MeanEncoder-
specific ones fixed here (unmodified base: 17 failed/326 passed);
remaining 13 are pre-existing and unrelated (numpy-X rejection per the
narwhals check_X() contract, affecting every encoder; OrdinalEncoder's
and WoEEncoder's own unmigrated fit() bugs on other in-progress
branches). flake8 and mypy clean. Module imports with pandas blocked
(verified in isolation from unmigrated sibling modules in the
encoding package, which still import pandas on this per-file
migration branch). sphinx -W build clean (only the pre-existing
linkcode_resolve warning). Verified the class docstring example and
every code example in docs/user_guide/encoding/MeanEncoder.rst that
doesn't require the Titanic dataset against real output, and added a
"With polars" section verified the same way; the Titanic-dataset
examples could not be re-run in this sandbox (no network access to
openml.org) but are untouched by this change. Downstream consumers
(feature_engine/_prediction/base_predictor.py and
target_mean_selection.py, which construct MeanEncoder internally)
verified via their test suites: 66 passed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@solegalli
solegalli force-pushed the narwhals-mean-encoder branch from 710a8de to edf4980 Compare August 30, 2026 22:46
check_X_y now returns a narwhals frame, so bind that to nw_X and keep the
original native X for _check_or_select_variables, _check_na,
_get_feature_names_in and the nwd.is_pandas_dataframe(X) fast-path check
(those helpers still expect native input, matching the CategoricalImputer
migration on narwhals-migration). The pandas value_counts/groupby fast
path is unchanged - X stays native so no rehydration is needed. The
narwhals branch reuses nw_X from check_X_y instead of nw.from_native(X).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@solegalli
solegalli force-pushed the narwhals-mean-encoder branch from edf4980 to d9fbf78 Compare August 30, 2026 22:51
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