Migrate MatchCategories to narwhals, add polars support - #1064
Merged
Merged
Conversation
MatchCategories now accepts pandas and polars dataframes. With pandas it keeps casting to the category dtype; with polars it casts to Enum with the categories learned in fit. Unseen categories become missing values in both. Also fixes the warning and error message when several integer-named pandas columns get missing values (it raised a TypeError). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
solegalli
force-pushed
the
narwhals-match-categories
branch
from
September 19, 2026 09:21
34f6b07 to
b87b773
Compare
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…docstring Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Migrates
MatchCategoriesto narwhals, so it takes pandas and polars dataframes.categorydtype with the categories learned infit, unseen categories become NaN. It uses its own native path.pl.Enum(categories)with the categories learned infit. Unseen categories become null.Enumis the polars dtype that fixes the category set, like pandascategory.pl.Categoricaldoesn't: it takes any string, so unseen categories would go through. Everything else works the same on both backends: the NaN checks, the warning or error when NaNs are introduced,return_empty,variablesandignore_format.fitandtransformreuse the mixins inencoding/base_encoder.py(_check_or_select_variables,_check_na,_check_transform_input_and_state,_get_feature_names_in).transformstill overrides the mixin'stransform.transformis now ~2.5x faster. It builds the categorical from integer codes (Categorical.from_codes(levels.get_indexer(x))) instead ofwhere(isin)followed by a newCategorical. It getsCategoricalfromnw.get_native_namespace, so there is noimport pandas. It also avoids pandas'Pandas4Warningthat the old code raised when the input was already categorical with different categories.make_df,frame_to_dict, full-messagematch=, init-parameter section first). pandas-only tests cover the exactcategorydtype, integer column names and the index. One polars-only test covers the exactEnumschema. The data is used only in this file, so I added noconftest.py, which avoids clashing with theMatchVariablesPR.Index(..., dtype='int64')instead ofInt64Index,StringArray,dtype='str'). I also removed a sentence that pointed to a non-existenterrorsparameter and documented the warning/error thatmissing_valuescontrols. Every example was run.Benchmarks
Each candidate was timed as the whole body of
fit/transform. Median of 9 runs, candidates alternated. 50 categories per variable, 1% unseen in the test set. Times in ms.pandas transform (implemented:
from_codes+get_indexer)Categorical(where(isin))astype("category").cat.set_categoriesfrom_codes(get_indexer)when/is_in+Enum** narwhals'
Enumgives an ordered pandas categorical with string categories, so it doesn't reproduce the current output anyway.pandas fit (the categories of each variable): the old
pd.Categorical(x).categoriesandx.astype("category").cat.categoriesare tied (e.g. 500k × 20: 258.6 vs 261.4 ms). They give identical results. I keptastype, which needs no pandas namespace.Whole transformer, pandas, main vs this branch (median of 7, two rounds):
The whole
fitis slower than onmain, e.g. 500k × 20: ~1030 ms vs ~520–610 ms. The time goes to the sharedfind_categorical_variables→_is_categorical_and_is_not_datetimeinvariable_handling: 0.67 of 0.96 s in a profile.MatchCategories' own code isn't the cause, and this PR doesn't touch that helper. See "Pre-existing issues".polars (implemented: narwhals, see "Needs decision")
implode, one pass)when/is_in+Enumcast(Enum, strict=False)Also measured and discarded for polars fit: narwhals
unpivot().unique(), about 2x slower than per column. Sorting in Python afterunique()tied with the narwhals per-column version. narwhals' lazyselect(unique())isn't supported. For polars transform, narwhalsreplace_strict(..., return_dtype=Enum)was about as fast aswhen/is_in, with noisy results.Behaviour
mainon 25 recorded cases. The comparison coversfit'scategory_dict_(values, dtype, Index type), the output frame (assert_frame_equal), dtypes and categories, warnings, errors, and checks that the input is not modified. The cases were: unseen categories withignore/raise, NaN in fit/transform withignore/raise,ignore_formaton float/int/bool/datetime, categorical input with unused categories in a non-sorted order, pandasstringdtype, mixed-type object, integer column names,return_empty, a custom index, and the docstring example. Differences:"... feature(s) 0, 1.". Before, this raisedTypeError: sequence item 0: expected str instance, int found.narwhals-migration, not from this PR:transformno longer reorders the columns to the training order. An empty (0-row) test set gets sklearn's message"Found array with 0 feature(s)..."instead of"0 feature(s)...".Enumkeeps its dtype categories, including unused ones, like pandas) and for every missing-value case. Differences are listed under "Needs decision".Tests
tests/test_preprocessing, one pytest call, base (origin/narwhals-migration) vs this branch:test_match_categories.pytests,test_check_estimator_from_feature_engine[MatchCategories]andtest_transformers_in_pipeline_with_set_output_pandas[MatchCategories].MatchCategories:test_check_estimator_from_sklearn[estimator0]. sklearn'scheck_estimatorpasses numpy arrays, whichcheck_Xrejects. Encoders fail the same way on the base branch. The other failures areMatchVariables, which is migrated in a separate PR.flake8 feature_engine testsis clean.mypy feature_enginegives the same 2 errors as the base (datetime_subtraction.py,log.py).Needs decision
ignore_format=Trueon polars.pl.Enumonly accepts strings. Casting numbers straight toEnumtreats them as physical indices, and polars deprecates it. I sort the categories in the original dtype, so they stay in numeric order, and then cast them to strings. So[3, 1, 10]givesEnum(["1", "3", "10"]), and the values in the output are strings. pandas keeps numeric categories. Alternatives:Enum.category_dict_values on polars. They are lists of strings (theEnumcategories), while pandas keeps apd.Index. Apl.Serieswould also work, but a list prints cleanly and is whatEnumtakes.fitand ~2x faster intransformat 500k+ rows with several variables (table above). I didn't add them: a polars-nativeelsebranch would break other narwhals backends. A third branch (pandas / polars / narwhals) would leave the narwhals fallback untested, because pyarrow isn't installed locally or in CI. Absolute times are small (500k × 20: fit 54 vs 11 ms, transform 26 vs 13 ms), and the wholefitis dominated by variable detection anyway. I can add the polars branch if you prefer speed here.pl.Categoricalinput has no per-column category set, so its categories are the sorted observed values.pl.Enuminput keeps its dtype categories, as pandas does forcategory.ignore_format=True) is treated as missing, like in pandas: it is not learned as a category, and it becomes null in the output.Pre-existing issues, not fixed
find_categorical_variables(via_is_categorical_and_is_not_datetime) dominatesfittime on both backends. On 500k × 20 it takes ~0.67 s of a 0.96 s pandasfitand ~0.37 s of a 0.44 s polarsfit. The cost comes from converting each column to a Python list to test for numbers and datetimes. This is shared by all encoders, so it's out of scope here.check_estimatortest fails because it passes numpy arrays (same for the encoders).