Conversation
…s support BaseSelector.transform() returns the retained features in the train set order, in the same library as the input (pandas X[features], narwhals select otherwise). BaseRecursiveSelector.fit() trains the estimators on native frames and returns (nw_X, y). The helpers in base_selection_functions no longer import pandas: correlations are computed with numpy (np.corrcoef, or matrix products for pairwise complete observations when there are missing values), and feature importances are pandas Series for pandas input and dicts otherwise. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
fit() no longer imports pandas and accepts any narwhals-supported dataframe. The numerical features are binned with numpy (the same intervals as EqualFrequencyDiscretiser and EqualWidthDiscretiser, learned from the basis set) and counted with np.bincount; the categories are counted with Series.factorize on pandas and a narwhals group_by otherwise. The dataframe is no longer copied into a basis and a test dataframe: a boolean mask selects the rows of each column. With pandas, split_col=None uses the index as before. Dataframes without an index, like polars, are split by the position of the rows. Also fixes cut_off=0 being ignored, the missing spaces in two error messages, and init accepting thresholds that are not numbers or 'auto'. 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.
Stacked on #1070 (selection base). Its commit shows in the diff until #1070 merges.
Summary
Migrates
DropHighPSIFeatures(feature_engine/selection/drop_psi_features.py) to narwhals.fit()accepts pandas, polars or any other narwhals-supported dataframe, andtransform()(inherited fromBaseSelector) returns the same library it receives. The module no longer imports pandas.How
fit()works now:Xinto a basis and a test dataframe,_basis_mask()returns a numpy boolean array, and each feature is sliced with it. The pandas branch keeps the old pandas code for the reference column / index (quantile,value_counts().sort_index().cumsum(),isin,<=). The narwhals branch does the same with narwhals series (value_counts(normalize=True),cum_sum,is_in,<=).EqualFrequencyDiscretiser/EqualWidthDiscretiser(np.quantile with the qcut nudge, or linspace with the 0.1% widening for constant values). Thennp.searchsorted+np.bincountcount the basis and test values per interval. The discretisers are no longer instantiated (see benchmarks and "Needs decision").Series.factorize(sort=True)once, thennp.bincounton the basis and test codes. Other backends: one narwhalsgroup_by(feature).agg(nw.col("__basis__").sum(), nw.len())(simple aggregations only), sorted by category.value_countsmerge), so the sum is done over the same values in the same order: the pandas PSI values are bit-identical tomain.Splitting by the index (
split_col=None)split_frac=0.5, the first half of the rows goes to the basis set;cut_off=250sends rows 0 to 250; a list cut-off selects those positions.cut_off_holds the position (e.g. 499.5 for 1000 rows), which is exactly what pandas returns for a defaultRangeIndex, so both backends give the same PSI values for the same data.The docstring (
split_col,cut_off_) and the user guide (split_colsection and a new "With polars" section) explain this for users.Benchmarks
Median of repeats with the versions alternated (whole
fit(): each version in its own subprocess, 5 repeats of best-of-3; steps: 7-21 repeats). Data: normal numerical columns plus 2 string columns (5 and 100 categories),variables="all", split by the index / row order.Whole
fit(), pandas.main= pandas code before this PR (the version on #1070 does not run). "narwhals only" = this branch with the pandas branches disabled, i.e. what theelsepath costs on pandas.mainSplitting by a date column instead gives the same picture (e.g. 10k × 20: 148 → 20 ms; 500k × 20: 603 → 416 ms, measured under load).
Whole
fit(), polars (there is no "before": the old code fails on polars): 500k × 5+2: 97 ms, 500k × 50+2: 718 ms, 1M × 20+2: 598 ms, 5M × 20+2: 2954 ms.Step: binning and counting the numerical features (per-feature loop, 10 equal-frequency bins; ms for all columns):
value_counts+ merge)bincountbincountnp.quantile+searchsorted+bincount(chosen)value_countsgroup_byvalue_countsnumpy per column is the fastest or within 3% everywhere (at 5M × 20 on polars the narwhals
group_bycounting is 3% faster;np.quantiledominates all variants). The one 2-D array only wins at 10k and loses up to 1.5x at 500k. I also timed the pieces of one column at 1M rows:np.quantile29 ms vs polarsquantile×11 74 ms;np.searchsorted32 ms vs polarssearch_sorted37 ms and polarscut+value_counts91 ms. Onlynp.quantilealso reproduces the discretisers' intervals bit for bit.Step: counting the categories of one feature (ms):
value_counts×2 +alignSeries.factorize+bincount(chosen, pandas)group_by+len(chosen, others)group_byThe old code (filter X twice,
DataFrame.value_counts, merge) took 5.8 / 16.2 / 63.6 ms at 10k / 100k / 500k × 5 categories. On polars, the narwhalsgroup_byand the native one are within 0.2 ms, and narwhals is faster at 5M × 1000, so theelsebranch stays in narwhals (it also serves other backends). The aggregation usessumandlenonly, so it stays a simple group-by on pandas-like backends. Splitting and the per-feature loop are shared by both backends, so the pandas-native branches (X[feature].to_numpy(),factorize, the index handling) are what the "narwhals only" column above measures: 2-33% faster at 10k-100k, 1-3% at 500k.Behaviour
origin/main(the last version where the pandas code runs: on Migrate the selection base classes and helpers to narwhals, add polars support #1070check_Xreturns a narwhals frame, so 31 of the 62 old tests fail there) and compared them with this branch:psi_values_(value and type, compared with==, not approximately),features_to_drop_,variables_,cut_off_(value and type), the transformed frame,get_support(),get_feature_names_out(), errors (type and message), and that the input is not modified. The cases cover every parameter (variablesNone/"all"/list/str,thresholdnumber/"auto", both strategies,bins2-50,split_frac,split_distinct,switch,min_pct_empty_binsincl. 0,missing_values,confirm_variables,p_value), split columns of every type (float, int, string, category, datetime, date objects, index withRangeIndex, shuffled, datetime and string index), cut-offs (number, 0, string, date/datetime/Timestamp, lists of numbers/strings/dates/datetimes), NaN in features and in the split column, inf, integer column names, nullableInt64/Float64,stringdtype, categories with unused levels, many categories, reordered columns at transform and the error paths. Differences: 9 cases, all listed below as fixes or in "Needs decision".features_to_drop_,variables_,cut_off_and the transformed data are equal. Differences are only in cases that raise on both backends with a different exception (e.g. comparing a datetime column with adatecut-off: pandasTypeError, polarsNotImplementedError), plus two noted below.Bugs fixed (each has a test that fails without the fix)
cut_off=0was ignored:if not self.cut_offtreated 0 as "not given" and split bysplit_fracinstead. Nowcut_off is Noneis checked (in__init__too).test_cut_off_zero_is_used.binsmessage: said "integer >= 1" butbins=1is rejected. Now "integer >= 2".threshold=Noneor a list passed__init__and failed infit()with aTypeError; a non-numericsplit_frac(e.g."0.5") raised aTypeErrorfrom the comparison. Both now raise theValueErrorof the parameter.strategyandmissing_valuescheck the type before the membership test (AGENTS.md).thresholdaccepts any real number (numbers.Real), so numpy numbers keep working.split_frac=0withcut_off=Noneraised "cut_off and split_frac cannot be both set to None" (0 is falsy); it now raises thesplit_fracrange error.Other differences
missing_values="ignore") raisedValueError: 0 feature(s) (shape=(0, 1)) while a minimum of 1 is required.from the discretiser. It still raises aValueError, now with a message that names the variable: "The variable var_1 has only missing values in the basis or in the test set, so its PSI can't be computed. Got 0 values in the basis set and 500 values in the test set."missing_values="ignore", inf values were rejected by the discretiser after the split; now the inf check runs before the split (same message). Only the order of errors changes when a dataframe has both inf and a split problem.cut_off=[]used to fall back tosplit_frac(empty list is falsy); it now selects no rows and raises the "number of rows" error.cut_off_computed from a datetime column is a Pythondatetime(pandas keepsnp.datetime64); both compare equal todatetime(...).Categoricalsplit column withsplit_fracis sorted and compared alphabetically and works. In pandas, an unorderedcategorysplit column raisesTypeError: Unordered Categoricals can only compare equality or not(pre-existing, not changed).cut_offfor a polars datetime column is cast to the column type, as pandas does withpd.to_datetime.Tests
tests/test_selection/test_drop_high_psi_features.pyis rewritten to the conventions:# init parameters(one test per error message, wrong values and types,match=re.escape(msg)with the full message) andtest_init_param_assignment, then# fit and transform, with the data as plain dicts in the file and every behaviour on both backends throughmake_df(isinstance+frame_to_dict). pandas-only tests: shuffled index, datetime index,categorydtype, integer column names.Two problems in the old tests are fixed:
pytest.approx(expected, 12)setsrel=12(1200% tolerance), so the PSI asserts accepted almost any value.test_missing_value_ignoredexpected the PSI without NaN forvar_3, which is not what the code returns (0.0340 vs 0.0377); it passed only because of the tolerance. The new tests use the default tolerance, with values checked againstmain.test_split_shuffled_df_*tests had noassert(set(base.index) == {...}alone).test_transform_feature_to_drop_not_presentis removed:BaseSelector.transform()(#1070) selects the retained features, so a missing dropped feature no longer raises; the base tests cover transform.tests/test_selection(compared withorigin/narwhals-selection-base, same command):DropHighPSIFeaturesentries intest_check_estimator_selectors.py. The remaining failures belong to selectors migrated in other PRs.flake8 feature_engine testsis clean.mypy feature_engine: the same 2 errors as the base ref (datetime_subtraction.py,log.py).Docs
split_col,cut_off_and the data-split paragraph explain the polars behaviour; the example output was truncated at row 10 and now shows the 20 rows; a short polars example was added.split_colsection explains row positions for polars; new "With polars" section (run, real output). All examples were run on this branch and onmainand give the same output. One was stale: Case 1 usedX['var_3'][250:] = ..., a chained assignment that doesn't modifyXin pandas 3 (it only warns), sovar_3was not shifted. It now usesX.loc[250:, 'var_3'], and the PSI ofvar_3shown is the real one, 0.8324552340063687 (the page said 1.342485289730313). The printedIndex([...], dtype='object')showsdtype='str'with pandas 3; I left it as in the other user guides.Needs decision
split_col=None(implemented, following Migrate BaseForecastTransformer to narwhals, add polars support #1065). The alternative is to raise an error asking forsplit_colwith dataframes without an index. Note that pandas keeps using the index labels, so a pandas dataframe with a non-default index and the same data in polars are split differently; that is inherent to pandas having an index.EqualFrequencyDiscretiser/EqualWidthDiscretiseron one-column frames per feature was slower than numpy: 9x at 10k rows and 1.3x at 500k with pandas, 3-8% with polars (step table above), because of the checks, frame building andwith_columnson each call, so_interval_limits()repeats their ~15 lines of interval code. The results are bit-identical, but the two copies must be kept in sync. Option: move the interval computation of the discretisers into shared functions (related to issue Decide where helper functions that belong to one class should live #1054) and call them from both; that touches the discretisation module, so I did not do it here._check_variable_number()is not called. The addendum says selectors call it after selecting the variables, butDropHighPSIFeatureshas always worked with one variable (e.g.variables="var_0", tested before and now). Calling it would make one-variable selections raise. I kept the old behaviour.Pre-existing issues, not fixed
categorycolumn insplit_colwithsplit_fracraisesTypeError: Unordered Categoricals can only compare equality or not(polars works).datecut-off with a datetime split column raises on both backends (the user guide already says to pass a datetime); with a list of dates it works.variablesgiven as a single string equal tosplit_colis not rejected in__init__(only lists are checked).find_categorical_and_numerical_variablestriggers a polarsDeprecationWarning(cat.get_categories()invariable_handling/_variable_type_checks.py::_is_categories_num) forCategoricalcolumns.split_colerror message reads "must be a string an integer or None" (missing comma); left unchanged.