Skip to content

Migrate CountEncoder/CountFrequencyEncoder to narwhals, add polars support - #1025

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

Migrate CountEncoder/CountFrequencyEncoder to narwhals, add polars support#1025
solegalli wants to merge 2 commits into
narwhals-migrationfrom
narwhals-count-frequency-encoder

Conversation

@solegalli

Copy link
Copy Markdown
Collaborator

Migrates CountFrequencyEncoder / CountEncoder to narwhals with polars support.

transform() / inverse_transform() already come dataframe-agnostic from CategoricalMethodsMixin. The work here is fit(): pandas' .value_counts().to_dict() per variable is replaced with narwhals Series.drop_nulls().value_counts(sort=True, normalize=...).

  • drop_nulls() first: narwhals' value_counts() has no dropna param and counts NaN as a category by default (pandas' default drops it). Without this a NaN category would pick up a real count instead of staying "unseen" under missing_values="ignore" — a silent behaviour change.
  • sort=True matches pandas' value_counts() descending-by-count default, so encoder_dict_ category order is unchanged (docstring doctest and user-guide output verified byte-for-byte).

Merge vs split: benchmarked pandas-native vs narwhals-on-pandas vs narwhals-on-polars (10k–1M rows × 1/2/10 cols × 5/50 categories). narwhals-on-pandas runs 1.3x–2.0x at 50k–100k rows but converges to 1.06x–1.2x by 200k–1M and the absolute cost stays trivial (<2ms extra at 50k, <6ms at 1M×10). fit() runs once per lifecycle, not per transform(), so no pandas/polars split.

Tests rewritten to one parametrized test per behaviour over make_df in [pd.DataFrame, pl.DataFrame]; integer-column-name and pandas-category-dtype tests kept pandas-only. pytest.raises(match=...) switched to re.escape (one error string has literal parens).

Verified: tests/test_encoding — 350 passed, 17 pre-existing failures with identical IDs to the pre-migration baseline (confirmed via git stash). flake8 / mypy clean, sphinx -W clean. Added a verified "With polars" section to the class docstring and CountEncoder.rst.


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:05
…pport

transform()/inverse_transform() came pre-migrated via base_encoder.py's
CategoricalMethodsMixin (already narwhals-generic and benchmarked). The
remaining work was fit(), which builds encoder_dict_ from pandas'
.value_counts().to_dict() per variable.

Replaced with narwhals Series.drop_nulls().value_counts(sort=True,
normalize=...), converted to a dict via to_list() on both columns.
Two behavioral gaps found and closed against the old pandas code:

- narwhals' value_counts() has no dropna param and counts NaN as a
  category by default, unlike pandas' value_counts(dropna=True)
  default. Without drop_nulls() first, a NaN category picked up a real
  count instead of staying an "unseen" category under missing_values=
  "ignore" - would have been a silent behavior change. Verified against
  the old code (pandas value_counts() drops NaN by default) that this
  wasn't already the case.
- sort=True (matching pandas' own value_counts() default, descending
  by count) rather than narwhals' own default of sort=False, so
  encoder_dict_ keeps the same category order as before - verified via
  the class docstring's doctest and the user guide's printed
  encoder_dict_ output, both unchanged byte-for-byte.

Benchmarked pandas-native vs narwhals-on-pandas vs narwhals-on-polars
at 10k-1M rows x 1/2/10 columns x 5/50 categories, warmed up first.
Also compared value_counts() against group_by().agg(nw.len()) as an
alternative - value_counts() was consistently faster (up to ~2x), so
kept the simpler API. Decision: merge into one narwhals path, no
pandas/polars branch. The numbers are noisier than the base's
transform() benchmark: at 50k-100k rows (the "realistic size" range
used for that decision) narwhals-on-pandas ran 1.3x-2.0x of
pandas-native, higher than the 1.06x-1.2x band that justified merging
the encode() hot path. But the ratio is dominated by fixed per-call
overhead, not genuine scaling cost - it converges to 1.06x-1.2x by
200k-1M rows, and the absolute cost stays trivial throughout (under
2ms extra at 50k rows, under 6ms extra at 1M rows x 10 columns).
Unlike encode(), fit() runs once per model lifecycle, not once per
transform() call, so that one-time cost doesn't compound. narwhals-on-
polars was consistently at or faster than pandas-native (0.8x-1.1x).
Given AGENTS.md's stated priority (readability first, add a fast path
only when a slow default isn't free), a pandas/polars split wasn't
justified here.

Rewrote test_count_frequency_encoder.py to one parametrized test per
behavior over @pytest.mark.parametrize("make_df", [pd.DataFrame,
pl.DataFrame]), replacing the module-level pandas-only fixtures
(df_enc, df_enc_rare, df_enc_na, df_vartypes) with local dict
constants both backends can build from, per the ArcsinTransformer/
PowerTransformer precedent. Kept test_column_names_are_numbers and
test_variables_cast_as_category pandas-only, since integer column
names and pandas category dtype are backend-specific per AGENTS.md.
Switched exact-message pytest.raises(match=...) checks to match=
re.escape(msg): one of the existing error strings contains literal
parentheses ("feature(s)"), which pytest.raises interprets as a regex
capture group and silently fails to match without escaping - caught
this while converting the tests, not a pre-existing bug in the old
code (the old tests used exact string equality, which doesn't have
this problem).

Verified: tests/test_encoding full suite - 350 passed, 17 pre-existing
failures, identical failing test IDs to the pre-migration baseline (10
in test_check_estimator_encoders.py's numpy-array-input rejection
checks, 3 MeanEncoder inverse_transform failures from mean_encoding.py's
still-unmigrated fit()); confirmed by running the same suite against
the unmodified code via git stash. flake8 and mypy clean. Module
imports with pandas blocked. sphinx -W build clean (only the
pre-existing unrelated linkcode_resolve warning, confirmed identical
against the unmodified code too). Added a verified "With polars"
section to both the class docstring and the CountEncoder.rst user
guide page.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
check_X now returns a narwhals frame, so bind it to nw_X and keep the
original native X for _check_or_select_variables / _check_na /
_get_feature_names_in (the variable_handling and _check_contains_na
helpers still branch on nwd.is_pandas_dataframe and expect native input,
matching the CategoricalImputer migration on narwhals-migration).
Drop the now-redundant nw.from_native(X) round-trip and its unused
`import narwhals as nw`; the fit() loop reuses nw_X from check_X.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@solegalli
solegalli force-pushed the narwhals-count-frequency-encoder branch from 8237d81 to 39816c4 Compare August 30, 2026 22:08
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