From 911fea1b251a55888eb1b44dc0af2d52bc398386 Mon Sep 17 00:00:00 2001 From: Soledad Galli Date: Wed, 26 Aug 2026 00:56:16 +0200 Subject: [PATCH 1/5] Migrate EqualWidthDiscretiser to narwhals, add 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 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 --- .../discretisation/EqualWidthDiscretiser.rst | 51 +++++++++++- feature_engine/discretisation/equal_width.py | 52 ++++++++---- .../test_equal_width_discretiser.py | 80 +++++++++++++------ 3 files changed, 136 insertions(+), 47 deletions(-) diff --git a/docs/user_guide/discretisation/EqualWidthDiscretiser.rst b/docs/user_guide/discretisation/EqualWidthDiscretiser.rst index bd149df24..16bfda029 100644 --- a/docs/user_guide/discretisation/EqualWidthDiscretiser.rst +++ b/docs/user_guide/discretisation/EqualWidthDiscretiser.rst @@ -48,9 +48,10 @@ potentially impact the model's performance in this scenario. EqualWidthDiscretiser --------------------- -Feture-engine's :class:`EqualWidthDiscretiser()` applies equal width discretisation to numerical variables. It uses -the `pandas.cut()` function under the hood to find the interval limits and then sort the continuous variables into -the bins. +Feture-engine's :class:`EqualWidthDiscretiser()` applies equal width discretisation to numerical variables. It finds +the interval limits from each variable's minimum and maximum value, then sorts the continuous variables into the +bins. It works with pandas, polars, and any other dataframe library supported by +`narwhals `_. You can specify the variables to be discretised by passing their names in a list when you set up the transformer. Alternatively, :class:`EqualWidthDiscretiser()` will automatically infer the data types and compute the interval limits for all numeric @@ -271,7 +272,7 @@ If we want to output the intervals limits instead of integers, we can set `retur .. code:: python # Set up the discretisation transformer - disc = EqualFrequencyDiscretiser( + disc = EqualWidthDiscretiser( bins=10, variables=['LotArea','GrLivArea'], return_boundaries=True) @@ -301,6 +302,48 @@ While we can't use these variables to train machine learning models, as opposed to the variables discretised into integers, they are very useful in this format for data analysis, and we can use any feature-engine encoder for further processing. +With polars +~~~~~~~~~~~ + +:class:`EqualWidthDiscretiser()` works in the same way with a polars dataframe: + +.. code:: python + + import polars as pl + from feature_engine.discretisation import EqualWidthDiscretiser + + df = pl.DataFrame({ + "x": [10400, 3675, 8640, 11670, 10667, 6120, 9500, 14000, 7200, 5300], + }) + + disc = EqualWidthDiscretiser(bins=5) + + print(disc.fit_transform(df)) + +The resulting values match those found with pandas: + +.. code:: text + + shape: (10, 1) + ┌─────┐ + │ x │ + │ --- │ + │ i64 │ + ╞═════╡ + │ 3 │ + │ 0 │ + │ 2 │ + │ 3 │ + │ 3 │ + │ 1 │ + │ 2 │ + │ 4 │ + │ 1 │ + │ 0 │ + └─────┘ + +`return_object`, `return_boundaries`, and `binner_dict_` work identically to the pandas examples above. + See Also -------- diff --git a/feature_engine/discretisation/equal_width.py b/feature_engine/discretisation/equal_width.py index bab5c5396..bdffc748d 100644 --- a/feature_engine/discretisation/equal_width.py +++ b/feature_engine/discretisation/equal_width.py @@ -3,7 +3,9 @@ from typing import List, Optional, Union -import pandas as pd +import narwhals as nw +import numpy as np +from narwhals.typing import IntoDataFrame, IntoSeries from feature_engine._check_init_parameters.check_init_input_params import ( _check_return_empty_is_bool, @@ -164,14 +166,14 @@ def __init__( self.return_empty = return_empty self.bins = bins - def fit(self, X: pd.DataFrame, y: Optional[pd.Series] = None): + def fit(self, X: IntoDataFrame, y: Optional[IntoSeries] = None): """ Learn the boundaries of the equal width intervals / bins for each variable. Parameters ---------- - X: pandas dataframe of shape = [n_samples, n_features] + X: dataframe of shape = [n_samples, n_features] The training dataset. Can be the entire dataframe, not just the variables to be transformed. y: None @@ -184,23 +186,39 @@ def fit(self, X: pd.DataFrame, y: Optional[pd.Series] = None): # fit binner_dict_ = {} - for var in variables_: - tmp, bins = pd.cut( - x=X[var], - bins=self.bins, - retbins=True, - duplicates="drop", - include_lowest=True, - ) - - # Prepend/Append infinities - bins = list(bins) - bins[0] = float("-inf") - bins[len(bins) - 1] = float("inf") - binner_dict_[var] = bins + if len(variables_) > 0: + # one narwhals call for every variable at once, instead of a + # get_column() round-trip per variable. + arr = nw.from_native(X, eager_only=True).select(variables_).to_numpy() + mins = arr.min(axis=0) + maxs = arr.max(axis=0) + for var, mn, mx in zip(variables_, mins, maxs): + binner_dict_[var] = _equal_width_edges(mn, mx, self.bins) self.binner_dict_ = binner_dict_ self.variables_ = variables_ self._get_feature_names_in(X) return self + + +def _equal_width_edges(mn: float, mx: float, bins: int) -> List[float]: + """Bin-edge computation matching pandas.cut(bins=int, duplicates="drop"): + widen a constant [mn, mx] by 0.1% so linspace still produces positive- + width bins, then collapse duplicate edges the same way. The outer edges + are then clipped to +-inf, same as the pre-migration code did to the + retbins output, so transform() never needs an out-of-range branch. + """ + if mn == mx: + mn = mn - 0.001 * abs(mn) if mn != 0 else -0.001 + mx = mx + 0.001 * abs(mx) if mx != 0 else 0.001 + + edges = np.linspace(mn, mx, bins + 1) + unique_edges = np.unique(edges) + if len(unique_edges) < len(edges) and len(edges) != 2: + edges = unique_edges + + edges_: List[float] = edges.tolist() + edges_[0] = float("-inf") + edges_[-1] = float("inf") + return edges_ diff --git a/tests/test_discretisation/test_equal_width_discretiser.py b/tests/test_discretisation/test_equal_width_discretiser.py index 88782af91..684c6ee29 100644 --- a/tests/test_discretisation/test_equal_width_discretiser.py +++ b/tests/test_discretisation/test_equal_width_discretiser.py @@ -1,23 +1,26 @@ +import numpy as np import pandas as pd +import polars as pl import pytest from sklearn.exceptions import NotFittedError from feature_engine.discretisation import EqualWidthDiscretiser -def test_automatically_find_variables_and_return_as_numeric(df_normal_dist): - # test case 1: automatically select variables, return_object=False +@pytest.mark.parametrize("make_df", [pd.DataFrame, pl.DataFrame]) +def test_automatically_find_variables_and_return_as_numeric(df_normal_dist, make_df): transformer = EqualWidthDiscretiser(bins=10, variables=None, return_object=False) - X = transformer.fit_transform(df_normal_dist) + X = transformer.fit_transform(make_df(df_normal_dist)) - # fit parameters + # ground truth bin edges via pandas.cut, same widening/duplicates-drop + # rules the new fit() replicates in plain numpy. _, bins = pd.cut(x=df_normal_dist["var"], bins=10, retbins=True, duplicates="drop") bins[0] = float("-inf") bins[len(bins) - 1] = float("inf") - # transform output - X_t = [x for x in range(0, 10)] - val_counts = [18, 17, 16, 13, 11, 7, 7, 5, 5, 1] + expected_codes = pd.cut( + df_normal_dist["var"], bins=list(bins), labels=False, include_lowest=True + ).to_numpy() # init params assert transformer.bins == 10 @@ -26,45 +29,70 @@ def test_automatically_find_variables_and_return_as_numeric(df_normal_dist): # fit params assert transformer.variables_ == ["var"] assert transformer.n_features_in_ == 1 - # transform params - assert (transformer.binner_dict_["var"] == bins).all() - assert all(x for x in X["var"].unique() if x not in X_t) - # in equal width discretisation, intervals get different number of values - assert all(x for x in X["var"].value_counts() if x not in val_counts) + assert np.allclose(transformer.binner_dict_["var"], bins) + # transform params: same bin codes on both backends + assert np.array_equal(np.asarray(X["var"]), expected_codes) -def test_automatically_find_variables_and_return_as_object(df_normal_dist): +@pytest.mark.parametrize("make_df", [pd.DataFrame, pl.DataFrame]) +def test_automatically_find_variables_and_return_as_object(df_normal_dist, make_df): transformer = EqualWidthDiscretiser(bins=10, variables=None, return_object=True) - X = transformer.fit_transform(df_normal_dist) - assert X["var"].dtypes == "O" + X = transformer.fit_transform(make_df(df_normal_dist)) + + if isinstance(X, pd.DataFrame): + assert X["var"].dtype == object + else: + assert X["var"].dtype == pl.Object + + +@pytest.mark.parametrize("make_df", [pd.DataFrame, pl.DataFrame]) +def test_constant_variable_produces_single_bin(make_df): + # fit()'s bin-edge widening for a zero-range variable (mn == mx): should + # still fit without error and place every value in the same bin, same as + # pandas.cut(bins=10) on a constant series. + df = pd.DataFrame({"var": [5.0] * 10}) + transformer = EqualWidthDiscretiser(bins=10) + X = transformer.fit_transform(make_df(df)) + + _, bins = pd.cut(x=df["var"], bins=10, retbins=True, duplicates="drop") + bins[0] = float("-inf") + bins[len(bins) - 1] = float("inf") + expected_codes = pd.cut( + df["var"], bins=list(bins), labels=False, include_lowest=True + ).to_numpy() + + assert transformer.binner_dict_["var"][0] == float("-inf") + assert transformer.binner_dict_["var"][-1] == float("inf") + assert np.array_equal(np.asarray(X["var"]), expected_codes) def test_error_when_bins_not_number(): - with pytest.raises(ValueError): + with pytest.raises(ValueError, match="bins must be an integer"): EqualWidthDiscretiser(bins="other") def test_error_if_return_object_not_bool(): - with pytest.raises(ValueError): + with pytest.raises(ValueError, match="return_object must be True or False"): EqualWidthDiscretiser(return_object="other") -def test_error_if_input_df_contains_na_in_fit(df_na): - # test case 3: when dataset contains na, fit method +@pytest.mark.parametrize("make_df", [pd.DataFrame, pl.DataFrame]) +def test_error_if_input_df_contains_na_in_fit(df_na, make_df): with pytest.raises(ValueError): transformer = EqualWidthDiscretiser() - transformer.fit(df_na) + transformer.fit(make_df(df_na)) -def test_error_if_input_df_contains_na_in_transform(df_vartypes, df_na): - # test case 4: when dataset contains na, transform method +@pytest.mark.parametrize("make_df", [pd.DataFrame, pl.DataFrame]) +def test_error_if_input_df_contains_na_in_transform(df_vartypes, df_na, make_df): with pytest.raises(ValueError): transformer = EqualWidthDiscretiser() - transformer.fit(df_vartypes) - transformer.transform(df_na[["Name", "City", "Age", "Marks", "dob"]]) + transformer.fit(make_df(df_vartypes)) + transformer.transform(make_df(df_na[["Name", "City", "Age", "Marks", "dob"]])) -def test_non_fitted_error(df_vartypes): +@pytest.mark.parametrize("make_df", [pd.DataFrame, pl.DataFrame]) +def test_non_fitted_error(df_vartypes, make_df): with pytest.raises(NotFittedError): transformer = EqualWidthDiscretiser() - transformer.transform(df_vartypes) + transformer.transform(make_df(df_vartypes)) From c5efec1b8241afa6f516265153b67961eae77b8e Mon Sep 17 00:00:00 2001 From: Soledad Galli Date: Mon, 14 Sep 2026 22:42:49 +0200 Subject: [PATCH 2/5] Use shared backend test fixtures and helpers in EqualWidthDiscretiser tests Build inputs from the data_normal_dist / data_vartypes / data_na fixtures on the backend under test instead of converting pandas frames (which needs pyarrow for polars, so the polars cases failed), check isinstance(X, make_df) plus to_dict() contents, and use pytest.raises(match=re.escape(msg)). Co-Authored-By: Claude Opus 5 --- .../test_equal_width_discretiser.py | 92 ++++++++++--------- 1 file changed, 48 insertions(+), 44 deletions(-) diff --git a/tests/test_discretisation/test_equal_width_discretiser.py b/tests/test_discretisation/test_equal_width_discretiser.py index 684c6ee29..85fe03583 100644 --- a/tests/test_discretisation/test_equal_width_discretiser.py +++ b/tests/test_discretisation/test_equal_width_discretiser.py @@ -1,26 +1,38 @@ +import re + +import narwhals as nw import numpy as np import pandas as pd -import polars as pl import pytest from sklearn.exceptions import NotFittedError from feature_engine.discretisation import EqualWidthDiscretiser +from tests.backend_helpers import to_dict +MSG_NA = ( + "Some of the variables in the dataset contain NaN. Check and " + "remove those before using this transformer." +) -@pytest.mark.parametrize("make_df", [pd.DataFrame, pl.DataFrame]) -def test_automatically_find_variables_and_return_as_numeric(df_normal_dist, make_df): - transformer = EqualWidthDiscretiser(bins=10, variables=None, return_object=False) - X = transformer.fit_transform(make_df(df_normal_dist)) +def _expected_bins_and_codes(values, n_bins): # ground truth bin edges via pandas.cut, same widening/duplicates-drop - # rules the new fit() replicates in plain numpy. - _, bins = pd.cut(x=df_normal_dist["var"], bins=10, retbins=True, duplicates="drop") + # rules the fit() replicates in plain numpy. + series = pd.Series(values) + _, bins = pd.cut(x=series, bins=n_bins, retbins=True, duplicates="drop") bins[0] = float("-inf") bins[len(bins) - 1] = float("inf") + codes = pd.cut(series, bins=list(bins), labels=False, include_lowest=True) + return bins, codes.tolist() + + +def test_automatically_find_variables_and_return_as_numeric( + make_df, data_normal_dist +): + transformer = EqualWidthDiscretiser(bins=10, variables=None, return_object=False) + X = transformer.fit_transform(make_df(data_normal_dist)) - expected_codes = pd.cut( - df_normal_dist["var"], bins=list(bins), labels=False, include_lowest=True - ).to_numpy() + bins, expected_codes = _expected_bins_and_codes(data_normal_dist["var"], 10) # init params assert transformer.bins == 10 @@ -31,39 +43,31 @@ def test_automatically_find_variables_and_return_as_numeric(df_normal_dist, make assert transformer.n_features_in_ == 1 assert np.allclose(transformer.binner_dict_["var"], bins) # transform params: same bin codes on both backends - assert np.array_equal(np.asarray(X["var"]), expected_codes) + assert isinstance(X, make_df) + assert to_dict(X)["var"] == expected_codes -@pytest.mark.parametrize("make_df", [pd.DataFrame, pl.DataFrame]) -def test_automatically_find_variables_and_return_as_object(df_normal_dist, make_df): +def test_automatically_find_variables_and_return_as_object(make_df, data_normal_dist): transformer = EqualWidthDiscretiser(bins=10, variables=None, return_object=True) - X = transformer.fit_transform(make_df(df_normal_dist)) - - if isinstance(X, pd.DataFrame): - assert X["var"].dtype == object - else: - assert X["var"].dtype == pl.Object + X = transformer.fit_transform(make_df(data_normal_dist)) + assert isinstance(X, make_df) + assert nw.from_native(X, eager_only=True).schema["var"] == nw.Object -@pytest.mark.parametrize("make_df", [pd.DataFrame, pl.DataFrame]) def test_constant_variable_produces_single_bin(make_df): # fit()'s bin-edge widening for a zero-range variable (mn == mx): should # still fit without error and place every value in the same bin, same as # pandas.cut(bins=10) on a constant series. - df = pd.DataFrame({"var": [5.0] * 10}) + data = {"var": [5.0] * 10} transformer = EqualWidthDiscretiser(bins=10) - X = transformer.fit_transform(make_df(df)) + X = transformer.fit_transform(make_df(data)) - _, bins = pd.cut(x=df["var"], bins=10, retbins=True, duplicates="drop") - bins[0] = float("-inf") - bins[len(bins) - 1] = float("inf") - expected_codes = pd.cut( - df["var"], bins=list(bins), labels=False, include_lowest=True - ).to_numpy() + _, expected_codes = _expected_bins_and_codes(data["var"], 10) assert transformer.binner_dict_["var"][0] == float("-inf") assert transformer.binner_dict_["var"][-1] == float("inf") - assert np.array_equal(np.asarray(X["var"]), expected_codes) + assert isinstance(X, make_df) + assert to_dict(X)["var"] == expected_codes def test_error_when_bins_not_number(): @@ -76,23 +80,23 @@ def test_error_if_return_object_not_bool(): EqualWidthDiscretiser(return_object="other") -@pytest.mark.parametrize("make_df", [pd.DataFrame, pl.DataFrame]) -def test_error_if_input_df_contains_na_in_fit(df_na, make_df): - with pytest.raises(ValueError): - transformer = EqualWidthDiscretiser() - transformer.fit(make_df(df_na)) +def test_error_if_input_df_contains_na_in_fit(make_df, data_na): + transformer = EqualWidthDiscretiser() + with pytest.raises(ValueError, match=re.escape(MSG_NA)): + transformer.fit(make_df(data_na)) -@pytest.mark.parametrize("make_df", [pd.DataFrame, pl.DataFrame]) -def test_error_if_input_df_contains_na_in_transform(df_vartypes, df_na, make_df): - with pytest.raises(ValueError): - transformer = EqualWidthDiscretiser() - transformer.fit(make_df(df_vartypes)) - transformer.transform(make_df(df_na[["Name", "City", "Age", "Marks", "dob"]])) +def test_error_if_input_df_contains_na_in_transform(make_df, data_vartypes, data_na): + transform_data = make_df( + {k: data_na[k] for k in ["Name", "City", "Age", "Marks", "dob"]} + ) + transformer = EqualWidthDiscretiser() + transformer.fit(make_df(data_vartypes)) + with pytest.raises(ValueError, match=re.escape(MSG_NA)): + transformer.transform(transform_data) -@pytest.mark.parametrize("make_df", [pd.DataFrame, pl.DataFrame]) -def test_non_fitted_error(df_vartypes, make_df): +def test_non_fitted_error(make_df, data_vartypes): + transformer = EqualWidthDiscretiser() with pytest.raises(NotFittedError): - transformer = EqualWidthDiscretiser() - transformer.transform(make_df(df_vartypes)) + transformer.transform(make_df(data_vartypes)) From 31244fc3cab0b9f2bc1c2d55aa145d9a2f92ebfe Mon Sep 17 00:00:00 2001 From: Soledad Galli Date: Tue, 15 Sep 2026 12:06:23 +0200 Subject: [PATCH 3/5] Use frame_to_dict after the shared helper rename in #1045 Co-Authored-By: Claude Opus 5 --- tests/test_discretisation/test_equal_width_discretiser.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/test_discretisation/test_equal_width_discretiser.py b/tests/test_discretisation/test_equal_width_discretiser.py index 85fe03583..e08ddca87 100644 --- a/tests/test_discretisation/test_equal_width_discretiser.py +++ b/tests/test_discretisation/test_equal_width_discretiser.py @@ -7,7 +7,7 @@ from sklearn.exceptions import NotFittedError from feature_engine.discretisation import EqualWidthDiscretiser -from tests.backend_helpers import to_dict +from tests.backend_helpers import frame_to_dict MSG_NA = ( "Some of the variables in the dataset contain NaN. Check and " @@ -44,7 +44,7 @@ def test_automatically_find_variables_and_return_as_numeric( assert np.allclose(transformer.binner_dict_["var"], bins) # transform params: same bin codes on both backends assert isinstance(X, make_df) - assert to_dict(X)["var"] == expected_codes + assert frame_to_dict(X)["var"] == expected_codes def test_automatically_find_variables_and_return_as_object(make_df, data_normal_dist): @@ -67,7 +67,7 @@ def test_constant_variable_produces_single_bin(make_df): assert transformer.binner_dict_["var"][0] == float("-inf") assert transformer.binner_dict_["var"][-1] == float("inf") assert isinstance(X, make_df) - assert to_dict(X)["var"] == expected_codes + assert frame_to_dict(X)["var"] == expected_codes def test_error_when_bins_not_number(): From 36fc595101eb5836980db397f79f7e77da02f77b Mon Sep 17 00:00:00 2001 From: Soledad Galli Date: Fri, 18 Sep 2026 13:24:07 +0200 Subject: [PATCH 4/5] Group init tests and match errors in EqualWidthDiscretiser tests Co-Authored-By: Claude Opus 5 --- .../test_equal_width_discretiser.py | 59 +++++++++++++------ 1 file changed, 41 insertions(+), 18 deletions(-) diff --git a/tests/test_discretisation/test_equal_width_discretiser.py b/tests/test_discretisation/test_equal_width_discretiser.py index e08ddca87..ebfc3bb3d 100644 --- a/tests/test_discretisation/test_equal_width_discretiser.py +++ b/tests/test_discretisation/test_equal_width_discretiser.py @@ -15,6 +15,40 @@ ) +# init parameters +@pytest.mark.parametrize("bins", ["other", 1.5, None, [10]]) +def test_error_when_bins_not_number(bins): + msg = f"bins must be an integer. Got {bins} instead." + with pytest.raises(ValueError, match=re.escape(msg)): + EqualWidthDiscretiser(bins=bins) + + +@pytest.mark.parametrize("return_object", ["other", 1, None]) +def test_error_if_return_object_not_bool(return_object): + msg = f"return_object must be True or False. Got {return_object} instead." + with pytest.raises(ValueError, match=re.escape(msg)): + EqualWidthDiscretiser(return_object=return_object) + + +@pytest.mark.parametrize( + "bins, return_object, return_boundaries, precision", + [(10, False, False, 3), (5, True, False, 1), (2, False, True, 7)], +) +def test_init_param_assignment(bins, return_object, return_boundaries, precision): + transformer = EqualWidthDiscretiser( + bins=bins, + return_object=return_object, + return_boundaries=return_boundaries, + precision=precision, + ) + assert transformer.bins == bins + assert transformer.return_object is return_object + assert transformer.return_boundaries is return_boundaries + assert transformer.precision == precision + + +# fit and transform + def _expected_bins_and_codes(values, n_bins): # ground truth bin edges via pandas.cut, same widening/duplicates-drop # rules the fit() replicates in plain numpy. @@ -34,10 +68,6 @@ def test_automatically_find_variables_and_return_as_numeric( bins, expected_codes = _expected_bins_and_codes(data_normal_dist["var"], 10) - # init params - assert transformer.bins == 10 - assert transformer.variables is None - assert transformer.return_object is False # fit params assert transformer.variables_ == ["var"] assert transformer.n_features_in_ == 1 @@ -55,9 +85,8 @@ def test_automatically_find_variables_and_return_as_object(make_df, data_normal_ def test_constant_variable_produces_single_bin(make_df): - # fit()'s bin-edge widening for a zero-range variable (mn == mx): should - # still fit without error and place every value in the same bin, same as - # pandas.cut(bins=10) on a constant series. + # a constant variable still fits, with every value in the same bin, as with + # pandas.cut(bins=10) data = {"var": [5.0] * 10} transformer = EqualWidthDiscretiser(bins=10) X = transformer.fit_transform(make_df(data)) @@ -70,16 +99,6 @@ def test_constant_variable_produces_single_bin(make_df): assert frame_to_dict(X)["var"] == expected_codes -def test_error_when_bins_not_number(): - with pytest.raises(ValueError, match="bins must be an integer"): - EqualWidthDiscretiser(bins="other") - - -def test_error_if_return_object_not_bool(): - with pytest.raises(ValueError, match="return_object must be True or False"): - EqualWidthDiscretiser(return_object="other") - - def test_error_if_input_df_contains_na_in_fit(make_df, data_na): transformer = EqualWidthDiscretiser() with pytest.raises(ValueError, match=re.escape(MSG_NA)): @@ -98,5 +117,9 @@ def test_error_if_input_df_contains_na_in_transform(make_df, data_vartypes, data def test_non_fitted_error(make_df, data_vartypes): transformer = EqualWidthDiscretiser() - with pytest.raises(NotFittedError): + msg = ( + "This EqualWidthDiscretiser instance is not fitted yet. Call 'fit' with " + "appropriate arguments before using this estimator." + ) + with pytest.raises(NotFittedError, match=re.escape(msg)): transformer.transform(make_df(data_vartypes)) From b7a81a62fc974df241da2c70aca8cbb6cb434d26 Mon Sep 17 00:00:00 2001 From: Soledad Galli Date: Fri, 18 Sep 2026 13:56:53 +0200 Subject: [PATCH 5/5] Move _equal_width_edges into EqualWidthDiscretiser as a private method Co-Authored-By: Claude Opus 5 --- feature_engine/discretisation/equal_width.py | 43 ++++++++++---------- 1 file changed, 21 insertions(+), 22 deletions(-) diff --git a/feature_engine/discretisation/equal_width.py b/feature_engine/discretisation/equal_width.py index bdffc748d..eaf09ddb3 100644 --- a/feature_engine/discretisation/equal_width.py +++ b/feature_engine/discretisation/equal_width.py @@ -193,7 +193,7 @@ def fit(self, X: IntoDataFrame, y: Optional[IntoSeries] = None): mins = arr.min(axis=0) maxs = arr.max(axis=0) for var, mn, mx in zip(variables_, mins, maxs): - binner_dict_[var] = _equal_width_edges(mn, mx, self.bins) + binner_dict_[var] = self._equal_width_edges(mn, mx, self.bins) self.binner_dict_ = binner_dict_ self.variables_ = variables_ @@ -201,24 +201,23 @@ def fit(self, X: IntoDataFrame, y: Optional[IntoSeries] = None): return self - -def _equal_width_edges(mn: float, mx: float, bins: int) -> List[float]: - """Bin-edge computation matching pandas.cut(bins=int, duplicates="drop"): - widen a constant [mn, mx] by 0.1% so linspace still produces positive- - width bins, then collapse duplicate edges the same way. The outer edges - are then clipped to +-inf, same as the pre-migration code did to the - retbins output, so transform() never needs an out-of-range branch. - """ - if mn == mx: - mn = mn - 0.001 * abs(mn) if mn != 0 else -0.001 - mx = mx + 0.001 * abs(mx) if mx != 0 else 0.001 - - edges = np.linspace(mn, mx, bins + 1) - unique_edges = np.unique(edges) - if len(unique_edges) < len(edges) and len(edges) != 2: - edges = unique_edges - - edges_: List[float] = edges.tolist() - edges_[0] = float("-inf") - edges_[-1] = float("inf") - return edges_ + def _equal_width_edges(self, mn: float, mx: float, bins: int) -> List[float]: + """Bin-edge computation matching pandas.cut(bins=int, duplicates="drop"): + widen a constant [mn, mx] by 0.1% so linspace still produces positive- + width bins, then collapse duplicate edges the same way. The outer edges + are then clipped to +-inf, same as the pre-migration code did to the + retbins output, so transform() never needs an out-of-range branch. + """ + if mn == mx: + mn = mn - 0.001 * abs(mn) if mn != 0 else -0.001 + mx = mx + 0.001 * abs(mx) if mx != 0 else 0.001 + + edges = np.linspace(mn, mx, bins + 1) + unique_edges = np.unique(edges) + if len(unique_edges) < len(edges) and len(edges) != 2: + edges = unique_edges + + edges_: List[float] = edges.tolist() + edges_[0] = float("-inf") + edges_[-1] = float("inf") + return edges_