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>
solegalli
force-pushed
the
narwhals-selection-base
branch
from
September 19, 2026 09:44
f484e11 to
cd7f30b
Compare
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This was referenced Sep 19, 2026
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 the shared selection code to narwhals so the selectors can be migrated one by one in follow-up PRs, with pandas and polars both supported:
BaseSelector.transform()returns the retained features in the column order of the train set, using the same library as the input. For pandas this isX[features], and for other backends it is a narwhalsselect._get_feature_names_in()accepts native or narwhals frames.BaseRecursiveSelector.fit()hands native frames to sklearn, and polars frames go to sklearn directly. It now returns(nw_X, y), matchingBaseNumericalTransformer._fit_setup. Variable selection reuses_select_numerical_variables.base_selection_functionsno longer imports pandas:find_correlated_features: the correlation matrix is computed with numpy (see benchmarks). When values are missing, each pair is still compared on the rows where both values are finite, asDataFrame.corr()does. This is vectorised with matrix products. Pairs where that sum is numerically unstable (for example a variable that is almost constant on the shared rows) are recomputed one by one. Kendall and callables loop over the pairs as pandas does, and for pandas input they still call.corr(). The grouping loop is vectorised with numpy and returns identical groups.single_feature_performance: builds a one-column native frame per feature.find_feature_importanceandBaseRecursiveSelector.feature_importances_(_std_): these are a pandas Series indexed by the features for pandas input, as before, and a dict{feature: value}for other backends (see Needs decision).thresholderror message now follows the convention:threshold must be an integer or a float. Got {threshold} instead.The individual selectors are untouched.
Benchmarks
Median of 3-5 alternating runs, times in ms. Other jobs were running on the machine, so treat small differences as noise.
find_correlated_features, whole function (main =
X[variables].corr()):Candidates tried for each piece, and which one won:
.corr(1218 ms at 500k × 50) vsnp.corrcoef(31 ms) vs polarsDataFrame.corr(54 ms, which is corrcoef underneath) vs polarspl.correxpressions per pair (102 ms). numpy wins..corr(926 ms at 500k × 50) vs narwhals per-pair expressions (4614 ms on polars) vs a numpy per-pair loop (5210 ms) vs vectorised numpy (101 ms pandas / 109 ms polars). Vectorised numpy wins, on both backends..corr(8956 ms at 500k × 50) vs pandasrank+ corrcoef vs scipyrankdata+ corrcoef (2905 ms), and on polars narwhalsrank+ corrcoef (79 ms at 500k × 10) vsrankdata(476 ms) vspl.corrper pair (600 ms). Winners: scipy for pandas, narwhals rank for polars..corr(), which is 6x faster than narwhals expressions on pandas. Polars uses narwhals per-pair expressions for spearman (3452 ms vs 3821 ms for a numpy loop at 500k × 10) and a numpy per-pair loop for kendall and callables.BaseSelector.transform, whole transform (half the columns dropped, input columns in reverse order):
X[fni].drop()X[keep]select(keep)select(fni).drop()X.select(keep)pandas keeps its own path. For polars, narwhals
selectis within 0.02-0.07 ms of native polars, so the non-pandas path uses narwhals.Frames handed to cross_validate (10 columns, 3-fold CV; time for all features):
X[f].to_frame()/X[[f]]get_column().to_frame()select(f)sklearn's fit dominates the timing, and how the frame is built makes no measurable difference. So one narwhals path is used for both backends. Passing numpy arrays is 10-30% faster with polars. It is not used because a user's estimator can be a pipeline that selects columns by name, which would break without the dataframe.
Behaviour
confirm_variables; all 4 correlation methods at 3 thresholds, with and without NaN, including constant and integer columns;single_feature_performanceandfind_feature_importancewith int, splitter, generator and groups CV and a numpy y; andBaseRecursiveSelectorwith RF, logistic regression, KNN (permutation importance), a variables subset,confirm_variablesand a generator CV. All 95 are identical on this branch.find_correlated_featureswas stress-tested on 1773 random datasets: ties, 2-20% missing values, inf, nullableInt64, shuffled variable order, 4 methods including a random callable, and 3 thresholds. Groups are identical between main and the pandas path, and between pandas and polars.Tests
test_base_selector.pyandtest_base_selection_functions.pyare rewritten to the conventions, andtest_base_recursive_selector.pyis new. Every test runs on pandas and polars throughmake_df, targets are built withmake_series, and there is one test each with list and numpy targets. pandas-only tests cover integer column names, the pandas index, and the Series type of the importances.data_classification(a dict) is added totests/test_selection/conftest.py; the existing pandas fixtures stay for the selector tests.tests/test_selection, run one file at a time: 177 on the base ref, 141 on this branch. No new failures, 36 fixed: DropFeatures, SelectByInformationValue and SelectBySingleFeaturePerformance now pass, as do some check_estimator, DropCorrelatedFeatures, SmartCorrelatedSelection and MRMR tests.tests/parametrize_with_checks_selection_v16.pyhas the same failures before and after.flake8 feature_engine testsis clean. mypy shows the same 2 errors as the base ref, indatetime_subtraction.pyandlog.py.Expected failures fixed by the follow-up selector PRs
None: no test that passed on the base ref fails on this branch.
Needs decision
feature_importances_/feature_importances_std_for non-pandas input (RFE, RFA, andfind_feature_importanceused by ProbeFeatureSelection). pandas users still get a pandas Series indexed by feature name. For polars I used a dict{feature: importance}: it keeps the feature names, needs no pandas, and matches the other dict attributes in the library. The alternatives are a polars DataFrame with feature and importance columns, or a dict for every backend, which would break code that relies on the pandas Series.Pre-existing issues, not fixed
check_numerical_variables(polars_df, ["Age", "Hola"])raises narwhalsColumnNotFoundErrorfor a column that is not in the dataframe, while pandas raisesKeyError. The difference is invariable_handling, and_select_numerical_variablesjust passes it on.