Conversation
SklearnWrapper (and its deprecated alias SklearnTransformerWrapper) now accepts pandas, polars and other narwhals-supported dataframes, and returns the same library it receives. - pandas keeps native inputs to scikit-learn; the wrapped transformer is set to return pandas output, which makes creators faster. - other backends pass numpy arrays to scikit-learn (None in text columns becomes NaN, as with pandas) and rebuild the output with narwhals, turning NaN back into null. - inverse_transform no longer modifies the input dataframe. - tests rewritten to the make_df conventions; user guide gains a polars example and fixes outdated examples. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
solegalli
force-pushed
the
narwhals-sklearn-wrapper
branch
from
September 19, 2026 09:33
e576cee to
05e9a73
Compare
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
SklearnWrapper(and the deprecated aliasSklearnTransformerWrapper, which still raises itsFutureWarning) now works with pandas, polars and any other eager dataframe supported by narwhals, and returns the library it receives.How data goes to and comes back from the wrapped scikit-learn transformer:
X[variables]goes to scikit-learn, and infitthe cloned transformer is set withset_output(transform="pandas")(exceptFunctionTransformer, which warns with it when its function returns an array). Creators (OneHotEncoder, PolynomialFeatures) then get a dataframe back with the input's index, which is joined withnw.concat(how="horizontal"). There is nopd.DataFrame(...)/pd.concatany more, so the module no longer imports pandas.nw_X.select(variables).to_numpy()goes to scikit-learn. If the array isobjectand the selection has nulls,Nonebecomesnp.nan, which is what scikit-learn gets from pandas. Without this, polars nulls in text columns are not imputed by SimpleImputer, and OrdinalEncoder/OneHotEncoder treat them as a category (cat_None). The output is rebuilt withnw.from_numpy(..., backend=nw_X.implementation), and NaN turns back into null (float arrays, only when NaN is present) so missing values stay missing for later polars steps.FunctionTransformergets the native polars frame, because the user's function is written for their dataframe. If it returns a dataframe, its columns are taken by position, like pandasX[cols] = dfdoes.X[kept], narwhalsselect) instead of reorder, then drop.set_outputalso protects the inner transformer from a globalsklearn.set_config(transform_output=...).Init:
issubclass(transformer.__class__, ...)becameisinstance(transformer, TransformerMixin). The TypeError message now endsGot {transformer} instead.(it wasgot ... instead.). I removed the unreachable "transformer has no inverse_transform" branch: every entry in_INVERSE_TRANSFORMhas the method.Benchmarks
Machine: Apple silicon, Python 3.14, pandas 3.0.3, polars 1.43, narwhals 2.24, scikit-learn 1.9. Times are in ms, median of 7 repeats; "before" is the last working pandas version (
ec26810^, beforecheck_Xreturned narwhals). Before and after ran in alternating processes, 3 times each, and the table shows the median of those. The machine was shared with other jobs, so differences under about 1 ms, or about 10% at 500k rows, are noise.Candidate implementations (transform only)
pandas, 500k rows (the size that drives the decision). "current" is the old code; "set_output" is what this PR uses; "narwhals" is
nw.from_numpy+with_columns/nw.concat:A hand-built
pd.DataFrame(arr, index=X.index, copy=False)performed about the same asset_output. I choseset_outputbecause it needs no pandas namespace and also protects against the global config. At 10k and 100k rows all variants are within 1 ms of each other.polars, 500k to 2M rows. The input goes to scikit-learn as native polars (
native_in) or as numpy (numpy_in), and the output is built withnw.from_numpy,nw.new_series,set_output("polars")or nativepl.from_numpy+with_columns/hstack:fitwith native vs numpy input was the same at every size (e.g. StandardScaler 1M/21: 85.5 vs 94.1, 2M/21: 224.5 vs 222.8). numpy input wins or ties everywhere, and it is the only option that gives pandas-equal results for nulls in text columns, so the choice is numpy input +nw.from_numpy. Turning NaN back into null costs about 1 ms for thenp.isnan(...).any()check when there are no NaN (500k x 15), and about 9 ms more at 500k x 15 when there are.inverse_transformon pandas (500k rows): shallowcopy(deep=False)+ column assignment 2.2 ms (6 cols) / 8.2 ms (21 cols), deepcopy()7.0 / 15.8, narwhalswith_columns3.2 / 7.5. I chose the shallow copy. Assigning whole columns replaces them in the copy and leaves the user's arrays alone, and a test checks that the input is unchanged.Whole fit / transform, pandas before vs after (ms)
"inverse before" did no copy; that was the in-place bug fixed below.
fitis the scikit-learn fit plus under 0.2 ms: at 500k x 21, wrapper fit took 5.2 ms and scikit-learn's own OneHotEncoder fit took 5.1 ms. So thefitdifferences in the table are noise.Whole fit / transform, polars (ms)
At the same size, polars creators are slower than pandas. There are two causes, and neither is in the wrapper's own logic. First, scikit-learn returns a row-major array: pandas wraps it without copying, while polars has to copy it into columns (
from_numpyof 500k x 15 floats costs about 20 ms with every constructor tried). Second, a polars text column must be converted to a numpy object array (about 9 ms per 500k rows), and scikit-learn does that same conversion when it gets polars directly.Behaviour
pandas: identical to the last working version (
ec26810^), checked on 94 recorded cases. The cases cover all 16 supported transformers, 7 creator and 4 selector configurations,variables=Noneand a list, reordered columns at transform, a custom index, NaN in numeric and text columns, integer column names, category dtype,return_empty, error paths and a globaltransform_outputconfig. Compared: columns, dtypes, index, values, warnings,variables_,features_to_drop_,get_feature_names_outandinverse_transform. The only difference is the bug fix below.polars: same values as pandas in every case, including missing values in text columns (imputed,
cat_nancolumn, NaN kept by OrdinalEncoder). Differences that come from the backend:null, not NaN.transformer_.feature_names_in_is not set for polars, because scikit-learn gets numpy. As a result,get_feature_names_out(input_features)for OneHotEncoder/PolynomialFeatures accepts any list of the right length on polars. On pandas, scikit-learn requiresinput_featuresto equal the fitted variables. Valid calls give the same names on both backends.(0, 0)polars frame, because polars cannot have rows without columns (pandas keeps the index).FunctionTransformer's function gets the polars frame, so its result follows polars rules. For example,lambda x: x + 1on an int and a float column gives two floats in polars.Bugs fixed
inverse_transformmodified the user's dataframe in place (X[variables] = ...on the input, becausecheck_Xdoes not copy). It now works on a shallow copy (pandas) or a new frame (narwhals).test_inverse_transformchecks that the input is unchanged and fails without the fix.Tests
tests/test_wrappers/test_sklearn_wrapper.pyis rewritten to the conventions:# init parameters(one test per error message, with wrong values and types,test_init_param_assignment, the deprecation warning with its full message), then# fit and transform. It usesmake_dffor both backends, plain dict data with explicit expected values,frame_to_dict/null_count/make_series, and one list/array target test. The pandas-only tests cover integer column names, the index and the category dtype. It no longer downloads the California housing dataset.test_check_estimator_wrappers.py,test_return_emptymoved (as two tests, on both backends) to the main file, and the remainingpytest.raisesgot a fullmatch=.Failing tests (
tests/test_wrappers, the only folder that imports the wrappers):narwhals-migrationThe remaining failure,
test_sklearn_transformer_wrapper, fails on the base and also onec26810^. scikit-learn'scheck_fit_score_takes_ypasses numpy arrays, andcheck_Xrejects them; imputers and outliers have the same failure.flake8 feature_engine testsis clean.mypy feature_enginereports the same 2 errors as the base (indatetime_subtraction.pyandlog.py).Docs
sparse=False, which scikit-learn >= 1.7 rejects; it now usessparse_output=False.data['embarked'].fillna('C', inplace=True)does nothing under pandas copy-on-write; it is now an assignment.cabinvalues the loader returns under pandas 3 (NaN).dtypes != 'O', which picks text columns on pandas 3 and fails. It now usesfind_numerical_variables.cabinvalue is 0, not 2. It was already wrong before this PR.Needs decision
fill_null-based feature-engine step (e.g.MeanImputer) still sees the missing values. scikit-learn's ownset_output("polars")returns NaN instead. It costs about 1 ms per 500k x 15 without NaN, and about 9 ms with NaN. Keep it, or return NaN like scikit-learn?Nonecategory. Keep it?got {transformer} instead.toGot {transformer} instead.to follow the convention.Pre-existing issues, not fixed
get_feature_names_outbuilds the new names by joining strings, so this fails before and after this PR.OrdinalEncoderinverse_transformfails on pandas 3 when the encoded column has NaN:ValueError: assignment destination is read-only. scikit-learn writes into the read-only array that pandas copy-on-write hands out, and calling scikit-learn directly fails the same way.check_estimatorfails oncheck_fit_score_takes_y, because it uses numpy input (see Tests).if getattr(transformer, "sparse", False)in__init__is dead code with scikit-learn >= 1.7, which has nosparseparameter. Left as is.