Skip to content

Migrate EqualWidthDiscretiser to narwhals, add polars support - #1040

Open
solegalli wants to merge 2 commits into
narwhals-migrationfrom
narwhals-equal-width-discretiser
Open

Migrate EqualWidthDiscretiser to narwhals, add polars support#1040
solegalli wants to merge 2 commits into
narwhals-migrationfrom
narwhals-equal-width-discretiser

Conversation

@solegalli

Copy link
Copy Markdown
Collaborator

Migrates EqualWidthDiscretiser to narwhals with polars support.

fit()'s only pandas dependency was pd.cut(bins=int, retbins=True, duplicates="drop"), used purely to compute equal-width bin edges from each variable's min/max (the discretised codes come from transform(), migrated on narwhals-discretisation-base). Replaced with _equal_width_edges(): a plain np.linspace(min, max, bins+1), reproducing pandas.cut's own edge computation exactly — verified against pandas 3.0's _nbins_to_bins/_bins_to_cuts source, including the mn == mx 0.1%-range widening for constant columns and the duplicates="drop" collapse for degenerate float edges. fit() now pulls all variables in one .select(variables_).to_numpy() call (min/max per column via axis=0), following CyclicalFeatures.fit().

Merge vs split: benchmarked old pandas-native (pd.cut per column) vs the new narwhals+numpy fit() at 10k/50k/100k rows × 1/2/10 cols. narwhals-on-pandas is faster everywhere except the smallest 10k/1-col case (2.58x slower, sub-ms); at realistic sizes 2–6x faster (100k×10: 19.3ms → 3.0ms). polars faster still. The new path is a speedup on pandas — no is_pandas branch, single numpy-driven path for every backend.

Verified binner_dict_ numerically identical to the old pd.cut-based fit() across 53 diff cases (random/int/negative values, constant columns, tiny near-duplicate float ranges, two-point and single-value arrays, bins=1) — zero mismatches.

Tests: test_equal_width_discretiser.py converted to one parametrized test per behaviour over [pd.DataFrame, pl.DataFrame]. Also fixed two vacuous assertions in the original numeric-output test (generator expressions checking truthiness of an always-empty sequence) with real comparisons against pd.cut ground truth, and added a constant-column case exercising the mn == mx widening branch. Fixed a pre-existing copy-paste bug in EqualWidthDiscretiser.rst (a "Return bin boundaries" example set up an EqualFrequencyDiscretiser).

Verified: tests/test_discretisation — 116 passed, same 5 pre-existing check_estimator failures. flake8 / mypy clean, sphinx -W clean. Runs fit_transform() on polars with pandas blocked at builtins.__import__.


Stacked on narwhals-discretisation-base (its own PR). Until that merges this PR's diff also contains the shared BaseDiscretiser commit; review that one first.

solegalli and others added 2 commits August 25, 2026 17:08
Shared base for ArbitraryDiscretiser, EqualFrequencyDiscretiser,
EqualWidthDiscretiser and GeometricWidthDiscretiser (not
DecisionTreeDiscretiser, which extends a different base). Only
transform() needed migrating - _fit_setup(), _get_feature_names_in()
and _check_transform_input_and_state() are inherited unchanged from
BaseNumericalTransformer, already fully narwhals-migrated.

transform()'s only pandas dependency was pd.cut, applied per column to
sort values into the bins already fixed by fit() (binner_dict_).
Replaced it with a plain numpy implementation: pandas.cut is itself
built on bins.searchsorted() internally (verified against pandas 3.0's
_bins_to_cuts source), so np.searchsorted + the same include_lowest
index-1 special case reproduces its bin-index logic exactly, with no
per-backend branch needed - values come from
nw_X.get_column(feature).to_numpy() regardless of backend, and results
are re-attached via nw.new_series()/with_columns(), so the same code
path runs for pandas and polars.

Benchmarked old pd.cut vs the new numpy+narwhals path at 10k/50k/100k
rows x 1/2/10 columns:
- return_boundaries=False (bin codes): narwhals-on-pandas lands at
  ~1.0-1.2x of pandas-native at realistic sizes (50k-100k rows, the
  ~1.9x seen only at the smallest 10k-row/1-col case is fixed
  per-call overhead, sub-millisecond either way) - minimal loss,
  merged into a single path, no is_pandas split. narwhals-on-polars is
  ~1.0-1.3x *faster* than pandas-native at every size tested.
- return_boundaries=True (interval-label strings): the numpy path is
  12-20x faster than pd.cut on pandas itself (e.g. 100k rows x 10
  cols: 647ms old vs 40ms new) - pd.cut's Categorical/IntervalIndex
  machinery has heavy per-call overhead that np.searchsorted plus
  plain string formatting avoids entirely. polars is ~1.2x faster
  still than the new pandas path.
Given both branches favour or are at parity with a single numpy-driven
path, there was no case for a pandas fast-path split here.

return_boundaries=True's interval-label formatting
("(lower, upper]" text, e.g. "(-0.001, 20.0]") replicates pandas.cut's
_round_frac/_infer_precision/lowest-edge-adjustment algorithm in pure
numpy so it works identically on both backends - verified against real
pd.cut(...).astype(str) output across positive/negative/duplicate-
inducing/inf-edge bins, and against the California housing dataset
used in the existing test. return_object=True now builds a nw.Object
column (narwhals' cross-backend equivalent of pandas' "O" dtype,
already used by variable_handling for categorical-column detection)
instead of a pandas-only astype("O") call.

Verified: tests/test_discretisation full suite unchanged (109 passed,
5 pre-existing failures in test_check_estimator_discretisers.py -
sklearn's check_estimator feeds raw numpy arrays, which check_X() has
rejected since the narwhals migration's dataframe-only contract;
reproduced identically on the unmodified file). Manually diffed
transform() output against real pd.cut() across ~10 edge cases (NaN,
out-of-range values on both ends, negative bins, exact-edge values,
precision auto-widening, single bin) plus the three sibling
discretisers' documented doctest examples (EqualWidthDiscretiser,
ArbitraryDiscretiser, EqualFrequencyDiscretiser value_counts()) -
all numerically identical to old pd.cut output; the "Name: x" vs
"Name: count" and bare-fit()-repr mismatches those doctests already
show are a pre-existing pandas-3.0 doc-staleness issue unrelated to
this migration (reproduced on the unmodified files too). flake8 and
mypy clean. Module imports with pandas blocked (loaded standalone,
since sibling discretiser files in this package are not yet migrated
and still import pandas at their own module level). sphinx -W build
clean (only the pre-existing unrelated linkcode_resolve warning).

test_base_discretizer.py's test_transform is now parametrized over
pd.DataFrame/pl.DataFrame per AGENTS.md - its MockClassFit hard-codes
binner_dict_ rather than actually fitting, so it needed no pandas-only
logic to begin with. The other four discretisers' own test files stay
pandas-only for now: their fit() methods still call pd.cut/pd.qcut
directly and aren't migrated by this branch.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
fit()'s only pandas dependency was pd.cut(bins=int, retbins=True,
duplicates="drop"), used purely to compute equal-width bin edges from
each variable's min/max (the discretised codes themselves come from
transform(), already migrated to numpy searchsorted on the prior
base_discretiser branch). Replaced it with _equal_width_edges(): a
plain numpy np.linspace(min, max, bins+1), reproducing pandas.cut's
own edge computation exactly - verified against pandas 3.0's
_nbins_to_bins/_bins_to_cuts source, including the mn==mx 0.1%-range
widening for constant columns and the duplicates="drop" collapse for
degenerate float edges. fit() now pulls all variables' values in one
nw.from_native(X).select(variables_).to_numpy() call (min/max per
column via axis=0), instead of one get_column() round-trip per
variable, following the pattern already used in CyclicalFeatures.fit().

Benchmarked old pandas-native (pd.cut per column) vs the new
narwhals+numpy fit() at 10k/50k/100k rows x 1/2/10 columns:
- narwhals-on-pandas is *faster* than the old pd.cut path everywhere
  except the smallest 10k-row/1-col case (2.58x slower there, but
  sub-millisecond either way - fixed per-call overhead). At realistic
  sizes (50k-100k rows) it's 2-6x faster; at 100k rows x 10 cols,
  19.3ms (old) vs 3.0ms (new).
- narwhals-on-polars is faster still at every size (e.g. 100k x 10:
  2.9ms).
Given the new path is a speedup rather than a loss on pandas, there
was no case for a pandas fast-path split (is_pandas branch) - fit()
is a single numpy-driven code path for every backend.

Verified binner_dict_ output is numerically identical to the old
pd.cut-based fit() across 53 diff cases (random/int/negative values,
constant columns at zero/positive/negative, tiny near-duplicate float
ranges, two-point and single-value arrays, bins=1) - zero mismatches.
Also verified full fit_transform() end-to-end against the class
docstring's documented value_counts() output (pre-existing "Name: x"
vs "Name: count" pandas-3.0 staleness noted in the base branch is
unrelated to this migration) and confirmed the module fit()/transform()
round-trip works on polars with pandas import blocked at the
interpreter level.

tests/test_discretisation/test_equal_width_discretiser.py: converted
to one parametrized test per behavior over
@pytest.mark.parametrize("make_df", [pd.DataFrame, pl.DataFrame]) per
AGENTS.md, replacing the pandas-only tests. Also fixed two vacuous
assertions in the original numeric-output test (generator expressions
that were checking truthiness of an always-empty filtered sequence,
so they passed regardless of correctness) with real value comparisons
against pd.cut ground truth, and added a dedicated constant-column
case exercising the new mn==mx widening branch that pd.cut used to
handle internally.

docs/user_guide/discretisation/EqualWidthDiscretiser.rst: verified
every existing example (binner_dict_, transformed head, dtypes,
return_boundaries output) against real output - all matched, no
changes needed to those values. Fixed a pre-existing copy-paste bug
(predates this migration) where the "Return bin boundaries" code
example set up an EqualFrequencyDiscretiser instead of
EqualWidthDiscretiser. Updated the "under the hood" description that
referenced pandas.cut specifically, and added a "With polars" section
with a verified worked example.

Verified: tests/test_discretisation full suite - 116 passed, same 5
pre-existing failures as the unmodified baseline (check_estimator
feeds raw numpy arrays, rejected by check_X() since the narwhals
migration's dataframe-only contract predates this branch). flake8 and
mypy clean. sphinx -W build clean (only the pre-existing unrelated
linkcode_resolve warning, confirmed identical on the unmodified
baseline). Module imports and runs fit_transform() on polars input
with pandas blocked at the builtins.__import__ level.

Co-Authored-By: Claude Sonnet 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