Skip to content

Add shared backend test fixtures and helpers for narwhals-migrated tests - #1045

Merged
solegalli merged 5 commits into
narwhals-migrationfrom
narwhals-test-backend-helpers
Sep 15, 2026
Merged

solegalli merged 5 commits into
narwhals-migrationfrom
narwhals-test-backend-helpers

Conversation

@solegalli

@solegalli solegalli commented Sep 15, 2026

Copy link
Copy Markdown
Collaborator

Standardises how the tests of narwhals-migrated transformers build pandas/polars inputs and check outputs, so every module follows one structure (modelled on the imputation tests) instead of a different set of helpers per transformer (_to_backend, _assert_values, _cols, _to_pandas, ...).

What this PR adds

  • tests/conftest.py: a make_df fixture parametrized over pd.DataFrame / pl.DataFrame (test ids pandas / polars). Tests that request it run once per backend; pandas-only tests simply don't request it.
  • tests/backend_helpers.py: to_dict(X) (contents as {column: values}, NaN normalised to None), null_count(X, col) and make_series(make_df, values).
  • tests/test_encoding/conftest.py: data shared by the encoder tests (data_enc, data_enc_rare, data_enc_na, data_enc_numeric, data_enc_big, data_enc_big_na, data_enc_top) as fixtures returning plain dicts, built with make_df(data).
  • CountEncoder tests (merged in Migrate CountEncoder/CountFrequencyEncoder to narwhals, add polars support #1025) migrated as the first consumer.

Conventions applied in the PRs stacked on this one

  • Data used by several test files lives in a module-local conftest.py as dict fixtures (missing values written as None); data used by one file stays in that file.
  • The target y is built with make_series (exercises the Series code path), plus one list / numpy array test for transformers that take y.
  • Outputs: assert isinstance(X, make_df) and a to_dict(X) comparison (pytest.approx for floats).
  • Errors and warnings: pytest.raises(..., match=re.escape(msg)) / pytest.warns(..., match=...).
  • pandas-only tests (category dtype, integer column names, ...) keep native fixtures and pd.testing.assert_frame_equal.

Stacked on this PR (review this one first): the imputation test PR, encoders #1026-#1032, outliers #1033-#1036, discretisation #1037-#1042 and scaling #1004.

Verification

  • Full tests/ suite: exactly the same failure set as narwhals-migration (573 pre-existing failures in not-yet-migrated modules and sklearn estimator checks), nothing new.
  • tests/test_encoding/test_count_frequency_encoder.py: 60 passed. flake8 clean.

solegalli and others added 2 commits September 14, 2026 22:13
…r tests

Tests for narwhals-migrated transformers each defined their own way of
building pandas/polars inputs and reading results back (_to_backend,
_assert_values, _cols, _to_dict, _to_pandas, ...), which makes the test
suite hard to maintain. Standardise on one structure:

- tests/conftest.py: `make_df` fixture parametrized over pd.DataFrame and
  pl.DataFrame (ids "pandas"/"polars"). Tests that request it run once per
  backend; pandas-only tests simply don't request it.
- tests/backend_helpers.py: `to_dict` (contents as {column: values}, NaN
  normalised to None), `null_count`, and `make_series` (target on the same
  backend as X).
- tests/test_encoding/conftest.py: data shared by the encoder tests, as
  fixtures returning plain dicts (missing values written as None) that tests
  build with make_df(data).

CountEncoder tests are migrated to this structure: they check the output is
of the input backend with isinstance(X, make_df) and compare contents with
to_dict().

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Shared by the OneHotEncoder, RareLabelEncoder and StringSimilarityEncoder
tests, which each defined their own copy of this data.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@solegalli
solegalli merged commit c6ee2d7 into narwhals-migration Sep 15, 2026
4 of 10 checks passed
@solegalli
solegalli deleted the narwhals-test-backend-helpers branch September 15, 2026 08:02
solegalli added a commit that referenced this pull request Sep 15, 2026
The shared helper was renamed from to_dict to frame_to_dict in #1045.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
solegalli added a commit that referenced this pull request Sep 15, 2026
…gData output type (#1046)

* Use shared backend test fixtures and helpers in imputation tests

Move the data duplicated across the imputer test files into
tests/test_imputation/conftest.py (data_na, and data_na_dob for the two
transformers that need a never-null datetime column), and replace the
file-local helpers (_cols, _null_count, _values, _same_values,
assert_df_equal, _missing_count, _to_list, _make_series) with the shared
ones: make_df fixture, to_dict, null_count and make_series. Every
transform output is now also checked to be of the input backend with
isinstance(X, make_df).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Fix DropMissingData returning a narwhals frame and skipping its pandas path

_select_rows() receives the narwhals frame returned by check_X, but still
dispatched with nwd.is_pandas_dataframe(X), which is never True for a
narwhals frame (narwhals warns about it). As a result:

- when no variable was selected (e.g. missing_only=True on a clean
  training set), transform() returned the narwhals frame itself instead
  of a pandas/polars dataframe;
- the benchmarked pandas fast path never ran, so pandas input silently
  went through the slower narwhals expression path.

Branch on X.implementation.is_pandas() instead, and always return the
native frame. Caught by the new isinstance(X, make_df) output checks.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Use frame_to_dict in imputation tests

The shared helper was renamed from to_dict to frame_to_dict in #1045.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* update conftest

* update arbitrary imputer tests

* update categorical imputer

* reorder tests in drop missing data

* refactor end tail tests

* refactor mean median imputer tests

* refactor random sampler and improve seeding procedure

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
solegalli added a commit that referenced this pull request Sep 15, 2026
The shared helper was renamed from to_dict to frame_to_dict in #1045.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
solegalli added a commit that referenced this pull request Sep 15, 2026
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
solegalli added a commit that referenced this pull request Sep 15, 2026
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
solegalli added a commit that referenced this pull request Sep 15, 2026
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
solegalli added a commit that referenced this pull request Sep 15, 2026
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
solegalli added a commit that referenced this pull request Sep 15, 2026
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
solegalli added a commit that referenced this pull request Sep 15, 2026
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
solegalli added a commit that referenced this pull request Sep 15, 2026
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
solegalli added a commit that referenced this pull request Sep 15, 2026
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
solegalli added a commit to ojassharma7/feature_engine that referenced this pull request Sep 15, 2026
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
solegalli added a commit that referenced this pull request Sep 15, 2026
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
solegalli added a commit that referenced this pull request Sep 15, 2026
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
solegalli added a commit that referenced this pull request Sep 15, 2026
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
solegalli added a commit that referenced this pull request Sep 15, 2026
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
solegalli added a commit that referenced this pull request Sep 15, 2026
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
solegalli added a commit that referenced this pull request Sep 15, 2026
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
solegalli added a commit that referenced this pull request Sep 15, 2026
* Migrate MeanEncoder.fit() to narwhals, add polars support

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>

* Adapt MeanEncoder to narwhals-returning check_X

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>

* Use shared backend test fixtures and helpers in MeanEncoder tests

Replace the file-local _to_backend/_assert_values helpers with the shared
test structure: make_df and data_enc* fixtures, y built with make_series on
the backend under test, isinstance(X, make_df) plus to_dict() checks, and
pytest.raises/warns(match=...). Add a test passing the target as a list and
as a numpy array, which take a different code path than a Series.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Use frame_to_dict in MeanEncoder tests

The shared helper was renamed from to_dict to frame_to_dict in #1045.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* refactor mean encoder

* Simplify MeanEncoder narwhals fit and drop init asserts from fit tests

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* refactor mean enc tests

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
solegalli added a commit that referenced this pull request Sep 15, 2026
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
solegalli added a commit that referenced this pull request Sep 15, 2026
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
solegalli added a commit that referenced this pull request Sep 15, 2026
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
solegalli added a commit that referenced this pull request Sep 15, 2026
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
solegalli added a commit that referenced this pull request Sep 15, 2026
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
solegalli added a commit that referenced this pull request Sep 15, 2026
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
solegalli added a commit that referenced this pull request Sep 15, 2026
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
solegalli added a commit that referenced this pull request Sep 15, 2026
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
solegalli added a commit that referenced this pull request Sep 15, 2026
* Migrate OrdinalEncoder.fit() to narwhals, add polars support

fit() has two paths: "arbitrary" (X[var].unique()) and "ordered"
(target mean per category, via y.groupby(X[var])). 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/sort for "ordered",
unique() for "arbitrary") at 10k-100k rows x 1-10 cols x 5-50
categories: it ran 5x-18x slower than pandas-native fit() at every
size tested - a large, consistent loss, unlike the ~1.1x seen for
the encode/transform hot path in base_encoder.py. Per the
benchmark-driven merge-vs-split rule, this is a real loss, so fit()
splits on `is_pandas = nwd.is_pandas_dataframe(X)`: pandas keeps a
close variant of its original groupby/unique code (confirmed via a
like-for-like full-class benchmark to run within noise of the old
code, ~1.0x), while polars (and any other narwhals backend) goes
through group_by()/agg()/sort()/unique(). New pandas branch differs
from the old code only in how "ordered" pairs y with X[var] (see bug
below) - "arbitrary" is untouched.

Two real issues found, confirmed against the unmodified pre-migration
file (both predate this migration):

1. Bug (fixed): the old "ordered" 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
   exactly the scenario tests/test_encoding/test_check_estimator_encoders.py
   ::test_encoders_when_x_pandas_y_numpy exercises for OrdinalEncoder
   (encoder2, added in 2022 for issue #376) - it failed against the
   unmodified file and now passes. Fixed on both the pandas branch
   (pair X[var] with y via `.assign()`, which aligns a numpy y
   positionally and a Series y by index, instead of `y.groupby(X[var])`)
   and the narwhals branch (`nw.new_series` for a numpy y).

2. Cross-backend ordering hazard (avoided, not a regression since old
   code was pandas-only): grouping by category then sorting by target
   mean does not, by itself, guarantee the same tie-break order on
   ties across backends - verified polars reversed two tied categories
   relative to pandas without it. Old pandas code effectively
   tie-broke on the category itself (pandas groupby sorts keys
   ascending by default, and sort_values() is stable). Reproduced that
   explicitly with a compound sort `.sort([target_name, var])` in the
   narwhals branch; verified pandas and polars now produce the same
   dict for a deliberately tied-mean fixture, matching the old code's
   order exactly.

Rewrote every test in test_ordinal_encoder.py as one
@pytest.mark.parametrize("make_df", [pd.DataFrame, pl.DataFrame]) case
per behavior (43 tests, up from 26), using a narwhals-based, NaN-aware
comparison helper. 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_ordinal_encoder.py 43 passed.
tests/test_encoding full suite: 344 passed, 16 failed - identical
failing test IDs to the unmodified base (17 failures, one of which
is the bug fixed above), all pre-existing and unrelated to
OrdinalEncoder (numpy-X rejection per the narwhals check_X() contract,
and MeanEncoder's own unmigrated fit() bug). flake8 and mypy clean.
Module imports with pandas blocked. sphinx -W build clean (only the
pre-existing linkcode_resolve warning, confirmed identical on the
unmodified base). Verified every code example in
docs/user_guide/encoding/OrdinalEncoder.rst against real output
(California Housing dataset) and added a "With polars" section,
verified the same way; the Titanic-dataset examples in that file
could not be re-run in this sandbox (no network access to openml.org)
but are untouched by this change and were not touched.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* Adapt OrdinalEncoder to narwhals-returning check_X

check_X / check_X_y now return 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 groupby/unique fast path is
unchanged - X stays native so no rehydration is needed. The narwhals
branch reuses nw_X from check_X / check_X_y instead of nw.from_native(X).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* Use shared backend test fixtures and helpers in OrdinalEncoder tests

Replace the file-local _to_backend/_assert_values helpers with the shared
test structure: make_df and data_enc* fixtures, y built with make_series on
the backend under test, isinstance(X, make_df) plus to_dict() checks, and
pytest.raises/warns(match=re.escape(msg)). Add a test passing the target as a
list and as a numpy array, which take a different code path than a Series.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Use frame_to_dict after the shared helper rename in #1045

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* refactor code

* Check encoding_method type, simplify OrdinalEncoder fit, group init tests

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Use add_target_to_X in OrdinalEncoder

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
solegalli added a commit that referenced this pull request Sep 15, 2026
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
solegalli added a commit that referenced this pull request Sep 15, 2026
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
solegalli added a commit that referenced this pull request Sep 15, 2026
* Migrate WoEEncoder to narwhals, add polars support

fit() splits by backend: pandas keeps _calculate_woe()'s existing
two-groupby implementation unchanged (it's directly unit-tested for that
exact pandas-Series-with-category-index contract); polars/other narwhals
backends use one group_by() instead of two, deriving the negative-class
count as the complement of the positive-class count per category -
benchmarked competitive with, and often faster than, pandas-native at
50k-100k rows. Zero-count-per-class fill_value handling preserved exactly.

Bug fix: _check_fit_input() previously assumed y was always a pandas
Series (y.nunique()/y.min()/y.max()), breaking on a numpy y (e.g. a plain
list/array-like target, which sklearn's check_X_y machinery converts via
column_or_1d). Wrapped numpy y into a narwhals Series aligned to X's
backend; for pandas specifically, also had to line the wrapped Series up
with X's actual index, since _calculate_woe()'s y.groupby(X[var]) aligns
by index and a mismatched default RangeIndex silently drops every row
instead of raising, leaving encoder_dict_ empty. Fixes
test_encoders_when_x_pandas_y_numpy's WoEEncoder case (was failing on the
unmigrated file, confirmed pre-existing).

Verified: 44/44 own tests, full encoding suite 342 passed/16 failed (was
17 pre-existing on the narwhals-encoding-base baseline - one less here
since this branch's own numpy-y bug is now fixed, rest confirmed
unrelated), flake8 and mypy clean, sphinx -W build clean (only the
pre-existing unrelated linkcode_resolve warning).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* Adapt WoEEncoder to narwhals-returning check_X

check_X_y now returns a narwhals frame. In _check_fit_input, bind that to
nw_X and keep the original native X: the nwd.is_pandas_dataframe(X) check,
the native_y.index = X.index alignment and the returned X all need native
input, and fit()'s pandas _calculate_woe fast path and nwd checks are then
unchanged (X stays native so no rehydration is needed). Take the y-series
backend from nw_X.implementation instead of re-wrapping X. In transform(),
bind _check_transform_input_and_state to nw_X, keep native X for
_check_contains_na, and pass nw_X to _encode (which now expects narwhals).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* Use shared backend test fixtures and helpers in WoEEncoder tests

Replace the file-local data dicts and assert_df_equal/_none_to_nan helpers
with the shared test structure: make_df and data_enc* fixtures, y built with
make_series on the backend under test, isinstance(X, make_df) plus to_dict()
checks, and pytest.raises/warns(match=re.escape(msg)). Add a test passing the
target as a list and as a numpy array, which take a different code path than
a Series.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Use frame_to_dict after the shared helper rename in #1045

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Use add_target_to_X in WoEEncoder, group init tests, match errors

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Compute WoE with narwhals in _calculate_woe, shared by WoEEncoder and SelectByInformationValue

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Replace zero counts by 0.5 in WoE, remove fill_value, add variables_with_zero_counts_

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
solegalli added a commit that referenced this pull request Sep 15, 2026
* 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>

* Adapt OneHotEncoder to narwhals-returning check_X

Bind check_X / _check_transform_input_and_state results to nw_X and keep
the original native X for _check_or_select_variables, _check_contains_na
and _get_feature_names_in (those helpers still expect native input,
matching the CategoricalImputer migration on narwhals-migration). Drop
the now-redundant nw.from_native(X) round-trips in fit() and transform();
they reuse the narwhals frame returned by check_X /
_check_transform_input_and_state.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* Use shared backend test fixtures and helpers in OneHotEncoder tests

Replace the file-local data dicts and _columns/_colsum helpers with the
shared test structure: make_df and data_enc* fixtures, isinstance(X, make_df)
plus to_dict() checks (keeping the column-order assertions, which are part of
this encoder's output contract), and pytest.raises(match=re.escape(msg)).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Use frame_to_dict after the shared helper rename in #1045

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Group OneHotEncoder init tests, match errors, shorten comments

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Match the fixed get_feature_names_out error message

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* refactor ohe

* fix code style

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
solegalli added a commit that referenced this pull request Sep 16, 2026
* Migrate RareLabelEncoder to narwhals, add polars support

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>

* Adapt RareLabelEncoder to narwhals-returning check_X

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>

* Use shared backend test fixtures and helpers in RareLabelEncoder tests

Replace the file-local data dicts and the _to_pandas helper - whose
polars to_pandas() call needs pyarrow, which is not a dependency, so the
polars cases failed - with the shared test structure: make_df and
data_enc_big* / data_enc_numeric fixtures, isinstance(X, make_df) plus
to_dict() checks, and pytest.raises/warns(match=re.escape(msg)).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Use frame_to_dict after the shared helper rename in #1045

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Group RareLabelEncoder init tests, match errors, shorten comments

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Fix typo in RareLabelEncoder replace_with error

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* minor refactor to tests

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
solegalli added a commit that referenced this pull request Sep 16, 2026
* Migrate StringSimilarityEncoder to narwhals, add polars support

fit() rebuilds encoder_dict_ with narwhals cast(nw.String)/value_counts,
matching the CountEncoder/RareLabelEncoder convention. cast() preserves
nulls as null on both pandas and polars (verified empirically), unlike
pandas' own astype(str) which stringifies NaN to "nan" - this lets
"impute" mode fill_null("") directly and "ignore" mode drop_nulls()
before casting, replacing the old "nan"/"<NA>" text-sentinel workaround
with a real null check (col.is_null()) that can't collide with a
genuine category literally named "nan" or "<NA>" (both edge cases stay
covered by test_string_dtype_with_literal_nan_strings).

transform()'s per-row difflib.SequenceMatcher similarity has no
vectorised narwhals equivalent, so it's computed once per unique value
via numpy broadcasting (np.unique's inverse index fans the small
per-unique-value matrix back out to all rows) and reassembled with
nw.new_series()/with_columns(), same pattern DecisionTreeFeatures uses
for externally-computed new columns.

Benchmarked a pandas-specific fast path (X.join(dict-of-columns), as
DecisionTreeFeatures uses) against the unified narwhals with_columns()
here across 10k-100k rows x 1-10 columns x 5-50 categories: assembly
overhead ranges 0.9x-6.25x depending on shape, but the difflib
computation itself dominates wall time by 1-3 orders of magnitude in
every realistic scenario (e.g. 30ms difflib vs <1ms assembly overhead
at 100k rows/20 categories) - even the worst synthetic case (500 output
columns) only costs ~10ms extra out of an already tens-of-ms-to-seconds
transform. Went with the unified/merged implementation: no is_pandas
split, one code path for both backends.

Rewrote tests as single parametrized cases over
@pytest.mark.parametrize("make_df", [pd.DataFrame, pl.DataFrame]),
keeping only the pandas-NA-sentinel tests (np.nan/pd.NA/None,
StringDtype) pandas-only since polars has no equivalent multi-sentinel
behavior to exercise. All doc examples (including the Titanic worked
example) re-verified against actual output; added a "With polars"
section.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* Adapt StringSimilarityEncoder to narwhals-returning check_X

Bind check_X / _check_transform_input_and_state results to nw_X and keep
the original native X for _check_or_select_variables 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(). The empty-variables short-circuit in
transform() now returns nw_X.to_native() so callers still get a native
frame.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* Use shared backend test fixtures and helpers in StringSimilarityEncoder tests

Replace the file-local data dicts and _to_pandas/_columns helpers with the
shared test structure: make_df and data_enc* fixtures, isinstance(X, make_df)
plus to_dict() checks, and pytest.raises(match=re.escape(msg)). Tests of
pandas-specific NA sentinels and the nullable string dtype stay pandas-only.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Use frame_to_dict after the shared helper rename in #1045

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Check missing_values type, group StringSimilarityEncoder init tests, match errors

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Match the fixed get_feature_names_out error message

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* refactor enc dict at the end

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
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