Skip to content

Migrate ProbeFeatureSelection to narwhals, add polars support - #1082

Open
solegalli wants to merge 3 commits into
narwhals-migrationfrom
narwhals-probe-feature-selection
Open

solegalli wants to merge 3 commits into
narwhals-migrationfrom
narwhals-probe-feature-selection

Conversation

@solegalli

Copy link
Copy Markdown
Collaborator

Stacked on #1070 (selection base classes). Its commit shows in the diff until #1070 is merged.

Summary

ProbeFeatureSelection now accepts pandas, polars and other dataframes supported by narwhals, and returns the same library it receives.

  • fit(): nw_X, y = check_X_y(X, y); the native X goes to _select_numerical_variables and _get_feature_names_in.
  • _generate_probe_features(n_obs) returns a dict {name: numpy array}. The numpy calls and their order did not change, so the probe values for a given random_state are the same as before.
  • Adding the probes:
    • pandas: DataFrame(probes, copy=False) + concat([X[variables_].reset_index(drop=True), probes], axis=1), called through nw.get_native_namespace(X) like _importance_series does, so there is no pandas import.
    • other backends: nw.from_dict(probes, backend=...) + nw.concat(..., how="horizontal").
  • fit() no longer runs X.reset_index(drop=True, inplace=True). It was safe only while check_X copied the input. Now that check_X does not copy, it would reset the user's index.
  • collective=False builds feature_importances_ / feature_importances_std_ with _importance_series, as the base does. They are a pandas Series for pandas input and a dict for other input.
  • _get_features_to_drop(probes) takes the probe names and reads the importances by key, so it works with both the Series and the dict. For a single probe it still compares against the 1-element array, which keeps the old behaviour for the degenerate cases described below.
  • probe_features_ is a dataframe in the input's library.
  • The docstring had the distribution option as 'binomial', which raises an error. It now says 'binary', the valid name.

Benchmarks

The machine was heavily loaded (load average about 45 on 10 cores), so the numbers are noisy. Each value is the median of 21-25 alternating repeats, in ms. Cross-validation takes almost all of fit(), so I timed the part the selector does itself: building the probe frame and appending it to X, with the probe arrays already generated.

pandas: DataFrame(copy=False) + concat is the fastest at every size.

rows cols probe cols old (df[c]=arr loop + concat) DataFrame + concat DataFrame(copy=False) + concat assign narwhals concat narwhals with_columns
10k 10 1 0.43 0.38 0.20 0.23 1.56 2.89
10k 50 12 2.09 0.57 0.24 2.07 5.05 12.75
10k 200 12 2.42 1.21 0.35 2.01 35.5 40.8
100k 10 12 3.44 3.23 0.52 5.40 5.48 7.39
100k 50 1 1.84 0.58 0.30 0.64 9.23 14.84
100k 200 12 2.73 3.23 0.41 4.00 40.3 80.5
500k 10 12 9.86 11.42 0.39 18.46 41.2 30.9
500k 50 12 9.32 13.21 0.36 18.87 46.8 46.6
500k 200 12 10.42 11.19 0.33 16.94 42.5 61.2

The two narwhals columns come from a separate run, so compare them with the other columns only roughly.

fit() with the cross-validation replaced by a stub (the selector's own work). Old = origin/main, whose check_X still copied X. Each cell lists 3 runs, each the median of 9 fits, in ms:

rows cols probes old new
100k 50 normal x1 8.4 / 28.2 / 11.7 3.6 / 1.7 / 3.0
100k 50 all x2 41.8 / 25.5 / 51.1 26.2 / 28.8 / 27.2
500k 10 normal x1 20.7 / 26.4 / 17.7 10.2 / 49.2 / 10.9
500k 50 normal x1 47.6 / 45.8 / 117.9 12.4 / 12.1 / 11.9
500k 50 all x2 184.9 / 219.8 / 121.3 126.7 / 100.3 / 126.9

polars: appending the probes costs well under 1 ms at 500k rows and more. Generating the probes with numpy costs 10-1000 ms, and polars-native hstack and narwhals concat are within noise of each other:

rows cols probe cols numpy generation polars hstack narwhals concat narwhals with_columns
500k 10 1 10.2 0.06 0.10 0.14
500k 50 12 253 0.37 0.63 0.95
500k 200 12 265 0.69 0.60 0.77
2M 10 12 1062 0.37 0.36 0.47
2M 50 12 1016 0.33 0.31 1.13
2M 200 1 72 5.25 2.26 3.50

I kept narwhals concat for the non-pandas path. It also covers the other backends, while hstack would need a third, polars-only branch to save at most about 0.3 ms.

Behaviour

  • pandas: identical to origin/main in 112 recorded cases. The cases cover:

    • collective True/False, every distribution, 'all' and lists, n_probes 1 and 3, the 3 thresholds
    • unseeded estimators, variables, confirm_variables, a splits generator, groups
    • NaN with HistGradientBoosting, a mixed numerical/categorical frame
    • integer column names, a non-default index, distribution=[] and n_probes=0

    In each case these match exactly: probe_features_ (values, dtypes, RangeIndex), feature_importances_ and _std_ (values, index), features_to_drop_, variables_, the transform output, the error messages, and the user's X staying untouched (same index).

  • polars: same values as pandas, bit for bit, in the 108 applicable cases. probe_features_ is a polars DataFrame, and the importances are dicts (see below).

  • All outputs in the user guide and the docstring were re-run: unchanged. I added the missing dtype: float64 line to one output.

Tests

tests/test_selection/test_probe_feature_selection.py is rewritten to the conventions:

  • init: one error test per message, with wrong values and wrong types, using match=re.escape, plus test_init_param_assignment.
  • fit/transform on both backends via make_df, using the data_classification fixture that is already in conftest.py. The expected importances are kept from the old tests.
  • pandas-only tests for integer column names and for leaving a non-default index alone (that one fails if fit resets the index in place).
  • probe generation and _get_features_to_drop unit tests, with both the Series and the dict container.

tests/test_selection failing tests: 137 on origin/narwhals-selection-base, 128 on this branch, no new failures. The 9 tests that now pass are the 5 old probe tests and 4 test_check_estimator_selectors cases for this class. tests/parametrize_with_checks_selection_v16.py -k Probe: the same 21 failures before and after. They are caused by numpy input to check_X, which fails for every migrated transformer.

flake8 feature_engine tests is clean. mypy feature_engine shows the same 2 errors as the base (in transformation/log.py).

Needs decision

Pre-existing issues, not fixed

  • fit() calls np.random.seed(random_state) and so resets numpy's global random generator. This makes unseeded estimators such as RandomForestClassifier() deterministic, and the user guide and tests rely on it. Switching to np.random.RandomState(random_state) would give the same probe values, but results with unseeded estimators would change, so I left it.
  • distribution=[] or n_probes <= 0 pass __init__, and then fit() fails with numpy's "The truth value of an empty array is ambiguous". The fix would be to validate both in __init__, which changes when and how the error is raised.
  • pandas input with integer column names and collective=True fails in scikit-learn, because the frame then mixes int and str column names (the probes). collective=False works.
  • n_probes=True is accepted as 1, because bool is a subclass of int.

solegalli and others added 3 commits September 19, 2026 11:44
…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>
fit() no longer resets the index of the input in place: pandas input gets
the probes in a new frame (DataFrame(copy=False) + concat, faster than
narwhals), other backends append them with a narwhals horizontal concat.
Probe values are identical for a given random_state. probe_features_ is a
dataframe in the input library; feature importances are a pandas Series for
pandas input and a dict otherwise, as in the base selectors.

Tests rewritten to run on pandas and polars; user guide and docstring get a
polars example.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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