From 8ec3881f0abb731b65157e243dfce2bd7cdc6929 Mon Sep 17 00:00:00 2001 From: Soledad Galli Date: Tue, 25 Aug 2026 17:14:04 +0200 Subject: [PATCH 01/10] Migrate DecisionTreeDiscretiser to narwhals, add polars support DecisionTreeDiscretiser now accepts pandas or polars input via narwhals, extending BaseNumericalTransformer directly (independent of the BaseDiscretiser migration). Never imports pandas; confirmed the module loads with pandas import blocked. Merge (single narwhals codepath, one is_pandas branch only at the final column reassembly) over split (branching at every column-selection call site): benchmarked at 10k/50k/100k rows x 1/2/10 cols, full fit+transform time is dominated by GridSearchCV tree training (10-1500ms) vs plumbing (~0.05-1.4ms per call, <1% of total even where a hand-branched pandas path was ~2x faster on the isolated plumbing microbenchmark). Merge also avoids the one-column-at-a-time write pattern that caused pandas fragmentation warnings in the DecisionTreeFeatures migration. Added optional n_jobs (default None = sequential, unchanged behaviour), parallelizing the per-variable tree fits with joblib threads, mirroring DecisionTreeFeatures. Benchmarked: net loss on small workloads (2 vars, small grid: 0.6-0.8x), real win once there's enough work (2-50 vars with a larger grid: 1.4-2.3x). Verified n_jobs=2 produces identical trees and predictions to n_jobs=None. Bug found and fixed (introduced by the base-transformer narwhals migration, not present pre-migration): check_X used to always copy its pandas input; the narwhals-based check_X no longer does, so the old transform()'s in-place `X[feature] = ...` assignments would have mutated the caller's original dataframe. Rewrote transform() to batch every replacement column and apply them in one non-mutating `.assign()` (pandas) / `.with_columns()` (polars) call instead, which also sidesteps polars' immutability and avoids per-column pandas fragmentation. Reimplemented pandas.cut's binning (bin_number/boundaries outputs) without importing pandas: np.digitize for bin assignment, and a from-scratch port of pandas' internal `_round_frac`/`_infer_precision` label-rounding algorithm (rounds each edge, bumping precision globally if that would collide two edges) so boundary labels are byte-for-byte identical to the old pd.cut output. Verified against pandas.cut directly across 500 randomized threshold/precision/value trials with zero mismatches, in addition to the existing hardcoded-value tests passing unmodified. Tests rewritten to one parametrized test per behavior over make_df in [pd.DataFrame, pl.DataFrame], replacing the pandas-only df_normal_dist/df_discretise fixtures with local data dicts (matching the DecisionTreeFeatures precedent, since those shared fixtures are still pandas-only). Fixed test_non_fitted_error, which was instantiating EqualWidthDiscretiser instead of DecisionTreeDiscretiser (a pre-existing copy-paste bug, confirmed present on main before this migration). tests/test_discretisation full suite: 123 passed (was 108 pre-migration, +15 from parametrization), same 5 pre-existing check_estimator failures (numpy-array input rejected by narwhals check_X, unrelated to this file, confirmed identical on the pre-migration baseline). flake8 and mypy clean. sphinx -W build produces only the pre-existing linkcode_resolve warning (confirmed identical on baseline). Docs: added "With polars" and "Training trees in parallel" sections, verified against real output (network available this session, so the existing fetch_openml house-prices example was re-run and confirmed still accurate). The two `binner_dict_` boundary/bin_number code blocks now display floats as plain numbers as before; current numpy's list repr actually renders them as np.float64(...), a numpy-version-only cosmetic drift present across the whole docs tree and not caused by this migration, left as-is and noted here instead. Co-Authored-By: Claude Sonnet 5 --- .../DecisionTreeDiscretiser.rst | 85 +++++++ .../discretisation/decision_tree.py | 210 +++++++++++++----- .../test_decision_tree_discretiser.py | 199 +++++++++++------ 3 files changed, 369 insertions(+), 125 deletions(-) diff --git a/docs/user_guide/discretisation/DecisionTreeDiscretiser.rst b/docs/user_guide/discretisation/DecisionTreeDiscretiser.rst index 80e994b8d..233f9ae97 100644 --- a/docs/user_guide/discretisation/DecisionTreeDiscretiser.rst +++ b/docs/user_guide/discretisation/DecisionTreeDiscretiser.rst @@ -443,6 +443,91 @@ were sorted: 799 0 9 380 0 9 +With polars +----------- + +:class:`DecisionTreeDiscretiser()` also accepts polars dataframes as input, and returns a polars +dataframe from `transform()`: + +.. code:: python + + import polars as pl + + X_train_pl = pl.DataFrame(X_train[["LotArea", "GrLivArea"]]) + + disc = DecisionTreeDiscretiser( + bin_output="prediction", + cv=3, + scoring="neg_mean_squared_error", + regression=True, + ) + disc.fit(X_train_pl, y_train) + + train_t = disc.transform(X_train_pl) + print(train_t.head()) + +.. code:: text + + shape: (5, 2) + ┌───────────────┬───────────────┐ + │ LotArea ┆ GrLivArea │ + │ --- ┆ --- │ + │ f64 ┆ f64 │ + ╞═══════════════╪═══════════════╡ + │ 144174.283688 ┆ 152471.713568 │ + │ 144174.283688 ┆ 191760.966667 │ + │ 176117.741848 ┆ 97156.25 │ + │ 144174.283688 ┆ 202178.409091 │ + │ 144174.283688 ┆ 202178.409091 │ + └───────────────┴───────────────┘ + +The predictions match those obtained with the pandas dataframe above. + +Training trees in parallel +--------------------------- + +:class:`DecisionTreeDiscretiser()` fits one decision tree per variable, independently of the +others. When there are many variables to discretise, or a large `param_grid` to search, training +can be parallelized across variables with the `n_jobs` parameter: + +.. code:: python + + import pandas as pd + from feature_engine.discretisation import DecisionTreeDiscretiser + + X = pd.DataFrame({ + "Age": [20, 44, 19, 33, 51, 40, 41, 37, 30, 54], + "Height": [164, 150, 178, 158, 188, 190, 168, 174, 176, 171], + "Marks": [1.0, 0.8, 0.6, 0.1, 0.3, 0.4, 0.8, 0.6, 0.5, 0.2], + }) + y = [4.1, 5.8, 3.9, 6.2, 4.3, 4.5, 7.2, 4.4, 4.1, 6.7] + + dtd = DecisionTreeDiscretiser(n_jobs=2, random_state=0) + dtd.fit(X, y) + + print(dtd.transform(X)) + +.. code:: text + + Age Height Marks + 0 4.533333 5.366667 4.100000 + 1 6.000000 5.366667 6.500000 + 2 4.533333 4.133333 4.133333 + 3 4.533333 5.366667 6.200000 + 4 6.000000 4.400000 4.400000 + 5 4.533333 4.400000 4.400000 + 6 6.000000 6.950000 6.500000 + 7 4.533333 4.133333 4.133333 + 8 4.533333 4.133333 4.133333 + 9 6.000000 6.950000 6.700000 + +`n_jobs` defaults to `None`, which trains the trees sequentially, matching this transformer's +original behaviour. Setting it trains multiple trees at the same time using threads, which only +pays off once there are enough variables or a large enough `param_grid` to outweigh the overhead +of dispatching work to threads — with just a handful of variables, sequential training is faster. +The resulting trees and predictions are identical regardless of `n_jobs`; only training speed +changes. + Additional considerations ------------------------- diff --git a/feature_engine/discretisation/decision_tree.py b/feature_engine/discretisation/decision_tree.py index 8af4b9b60..f49e51bb8 100644 --- a/feature_engine/discretisation/decision_tree.py +++ b/feature_engine/discretisation/decision_tree.py @@ -3,8 +3,11 @@ from typing import Dict, List, Optional, Union +import narwhals as nw +import narwhals.dependencies as nwd import numpy as np -import pandas as pd +from joblib import Parallel, delayed +from narwhals.typing import IntoDataFrame, IntoSeries from sklearn.model_selection import GridSearchCV from sklearn.tree import DecisionTreeClassifier, DecisionTreeRegressor from sklearn.utils.multiclass import check_classification_targets, type_of_target @@ -30,6 +33,58 @@ from feature_engine.tags import _return_tags +def _round_bin_edge(x: float, precision: int) -> float: + """Round a bin edge the way pandas.cut historically formatted Interval labels: + -inf/inf/0 pass through unrounded, and numbers with magnitude < 1 get extra + decimals so that `precision` significant digits survive past the leading + zeros (e.g. -0.0942 at precision=3 keeps 4 decimals, not 3, since + round(-0.0942, 3) == -0.094 would only keep 2 significant digits). + """ + if not np.isfinite(x) or x == 0: + return x + frac, whole = np.modf(x) + if whole == 0: + digits = -int(np.floor(np.log10(abs(frac)))) - 1 + precision + else: + digits = precision + return round(x, digits) + + +def _infer_bin_precision(thresholds: List[float], precision: int) -> int: + """Find the smallest precision >= `precision` at which every rounded + threshold is still distinct, mirroring pandas.cut's behaviour of bumping + precision (for every edge, not just the colliding pair) when the requested + precision would make two adjacent bin edges collide.""" + for prec in range(precision, 20): + rounded = [_round_bin_edge(t, prec) for t in thresholds] + if len(set(rounded)) == len(thresholds): + return prec + return precision + + +def _format_bin_edge(x: float, precision: int) -> str: + if x == -np.inf: + return "-inf" + if x == np.inf: + return "inf" + return str(_round_bin_edge(x, precision)) + + +def _bin_labels(thresholds: List[float], precision: int) -> List[str]: + """Build the `(left, right]` interval label for every bin delimited by + `thresholds`, which starts with -inf and ends with inf.""" + precision = _infer_bin_precision(thresholds, precision) + edges = [_format_bin_edge(t, precision) for t in thresholds] + return [f"({edges[i]}, {edges[i + 1]}]" for i in range(len(edges) - 1)] + + +def _bin_index(values: np.ndarray, thresholds: List[float]) -> np.ndarray: + """Map each value to the 0-indexed bin delimited by `thresholds` (which + starts with -inf and ends with inf), bins being closed on the right. + """ + return np.digitize(values, thresholds[1:-1], right=True) + + @Substitution( variables=_variables_numerical_docstring, variables_=_variables_attribute_docstring, @@ -115,6 +170,15 @@ class DecisionTreeDiscretiser(BaseNumericalTransformer): DecisionTreeClassifier(). For reproducibility it is recommended to set the random_state to an integer. + n_jobs: int, default=None + The number of jobs to run in parallel when training the decision trees + across variables. Trees are fit using threads rather than processes, + since fitting a decision tree releases the GIL for the bulk of its + computation, which avoids the overhead of copying the entire dataframe + to separate worker processes. `None` means 1, i.e. sequential training + (this transformer's original behaviour); `-1` means using all available + processors. + Attributes ---------- binner_dict_: @@ -163,9 +227,10 @@ class DecisionTreeDiscretiser(BaseNumericalTransformer): >>> dtd = DecisionTreeDiscretiser(random_state=42) >>> dtd.fit(X, y_reg) >>> dtd.transform(X)["x"].value_counts() + x -0.090091 90 - 0.479454 10 - Name: x, dtype: int64 + 0.479454 10 + Name: count, dtype: int64 You can also apply this for classification problems adjusting the scoring metric. @@ -173,9 +238,27 @@ class DecisionTreeDiscretiser(BaseNumericalTransformer): >>> dtd = DecisionTreeDiscretiser(regression=False, scoring="f1", random_state=42) >>> dtd.fit(X, y_clf) >>> dtd.transform(X)["x"].value_counts() + x 0.480769 52 0.687500 48 - Name: x, dtype: int64 + Name: count, dtype: int64 + + With polars: + + >>> import polars as pl + >>> X = pl.DataFrame({"x": X["x"].to_list()}) + >>> dtd = DecisionTreeDiscretiser(random_state=42) + >>> dtd.fit(X, y_reg) + >>> dtd.transform(X)["x"].value_counts() + shape: (2, 2) + ┌───────────┬───────┐ + │ x ┆ count │ + │ --- ┆ --- │ + │ f64 ┆ u32 │ + ╞═══════════╪═══════╡ + │ -0.090091 ┆ 90 │ + │ 0.479454 ┆ 10 │ + └───────────┴───────┘ """ def __init__( @@ -189,6 +272,7 @@ def __init__( param_grid: Optional[Dict[str, Union[str, int, float, List[int]]]] = None, regression: bool = True, random_state: Optional[int] = None, + n_jobs: Optional[int] = None, ) -> None: if bin_output not in ["prediction", "bin_number", "boundaries"]: @@ -223,9 +307,10 @@ def __init__( self.variables = _check_variables_input_value(variables) self.param_grid = param_grid self.random_state = random_state + self.n_jobs = n_jobs self.return_empty = return_empty - def fit(self, X: pd.DataFrame, y: pd.Series): + def fit(self, X: IntoDataFrame, y: IntoSeries): """ Fit one decision tree per variable to discretise with cross-validation and grid-search for hyperparameters. @@ -233,11 +318,11 @@ def fit(self, X: pd.DataFrame, y: pd.Series): 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: pandas series. + y: Series. Target variable. Required to train the decision tree. """ # confirm model type and target variables are compatible. @@ -259,25 +344,18 @@ def fit(self, X: pd.DataFrame, y: pd.Series): else: param_grid = {"max_depth": [1, 2, 3, 4]} - binner_dict_ = {} - scores_dict_ = {} + nw_X = nw.from_native(X, eager_only=True) + X_subs = [nw_X.get_column(var).to_frame().to_native() for var in variables_] - for var in variables_: - - if self.regression: - model = DecisionTreeRegressor(random_state=self.random_state) - else: - model = DecisionTreeClassifier(random_state=self.random_state) - - tree_model = GridSearchCV( - model, cv=self.cv, scoring=self.scoring, param_grid=param_grid - ) + fitted = Parallel(n_jobs=self.n_jobs, prefer="threads")( + delayed(self._fit_one_tree)(X_sub, y, param_grid) for X_sub in X_subs + ) - # fit the model to the variable - tree_model.fit(X[var].to_frame(), y) - - binner_dict_[var] = tree_model - scores_dict_[var] = tree_model.score(X[var].to_frame(), y) + binner_dict_ = dict(zip(variables_, fitted)) + scores_dict_ = { + var: tree_model.score(X_sub, y) + for var, X_sub, tree_model in zip(variables_, X_subs, fitted) + } if self.bin_output != "prediction": for var in variables_: @@ -296,64 +374,86 @@ def fit(self, X: pd.DataFrame, y: pd.Series): return self - def transform(self, X: pd.DataFrame) -> pd.DataFrame: + def transform(self, X: IntoDataFrame) -> IntoDataFrame: """ Replaces original variable values with the predictions of the tree. The decision tree predictions are finite, aka, discrete. Parameters ---------- - X: pandas dataframe of shape = [n_samples, n_features] + X: dataframe of shape = [n_samples, n_features] The input samples. Returns ------- - X_new: pandas dataframe of shape = [n_samples, n_features] + X_new: dataframe of shape = [n_samples, n_features] The dataframe with transformed variables. """ - # check input dataframe and if class was fitted X = self._check_transform_input_and_state(X) + is_pandas = nwd.is_pandas_dataframe(X) + nw_X = nw.from_native(X, eager_only=True) + + # build every replacement column before touching X, so pandas gets a + # single non-mutating `.assign()` and polars a single `.with_columns()` + # instead of one column swap per variable (avoids fragmentation and, + # since check_X no longer copies pandas input, avoids mutating the + # dataframe the caller passed in). + new_columns: Dict[str, np.ndarray] = {} + if self.bin_output == "prediction": for feature in self.variables_: - if self.regression: - preds = self.binner_dict_[feature].predict(X[feature].to_frame()) - if self.precision is None: - X[feature] = preds - else: - X[feature] = np.round(preds, self.precision) + X_sub = nw_X.get_column(feature).to_frame().to_native() + if self.regression is True: + preds = self.binner_dict_[feature].predict(X_sub) else: - tmp = self.binner_dict_[feature].predict_proba( - X[feature].to_frame() - ) - preds = tmp[:, 1] - if self.precision is None: - X[feature] = preds - else: - X[feature] = np.round(preds, self.precision) + preds = self.binner_dict_[feature].predict_proba(X_sub)[:, 1] + if self.precision is not None: + preds = np.round(preds, self.precision) + new_columns[feature] = preds elif self.bin_output == "boundaries": + # __init__ already guarantees precision is set when bin_output is + # "boundaries"; assert narrows the type for mypy. + assert self.precision is not None for feature in self.variables_: - X[feature] = pd.cut( - X[feature], - self.binner_dict_[feature], - precision=self.precision, - include_lowest=True, - ) - X[self.variables_] = X[self.variables_].astype(str) + thresholds = self.binner_dict_[feature] + labels = _bin_labels(thresholds, self.precision) + values = nw_X.get_column(feature).to_numpy() + bin_idx = _bin_index(values, thresholds) + new_columns[feature] = np.array(labels)[bin_idx] else: for feature in self.variables_: - X[feature] = pd.cut( - X[feature], - self.binner_dict_[feature], - labels=False, - include_lowest=True, - ) + thresholds = self.binner_dict_[feature] + values = nw_X.get_column(feature).to_numpy() + new_columns[feature] = _bin_index(values, thresholds) + + if is_pandas is True: + X = X.assign(**new_columns) + else: + new_series = [ + nw.new_series(name, values, backend=nw_X.implementation) + for name, values in new_columns.items() + ] + X = nw_X.with_columns(*new_series).to_native() return X + def _fit_one_tree(self, X_sub: IntoDataFrame, y: IntoSeries, param_grid: Dict): + """Instantiate and fit one decision tree on one variable.""" + if self.regression is True: + model = DecisionTreeRegressor(random_state=self.random_state) + else: + model = DecisionTreeClassifier(random_state=self.random_state) + + tree_model = GridSearchCV( + model, cv=self.cv, scoring=self.scoring, param_grid=param_grid + ) + tree_model.fit(X_sub, y) + return tree_model + def _more_tags(self): tags_dict = _return_tags() tags_dict["variables"] = "numerical" diff --git a/tests/test_discretisation/test_decision_tree_discretiser.py b/tests/test_discretisation/test_decision_tree_discretiser.py index a90d64ab8..2e7f222ac 100644 --- a/tests/test_discretisation/test_decision_tree_discretiser.py +++ b/tests/test_discretisation/test_decision_tree_discretiser.py @@ -1,9 +1,37 @@ +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 DecisionTreeDiscretiser, EqualWidthDiscretiser +from feature_engine.discretisation import DecisionTreeDiscretiser + + +def _normal_dist_data(): + np.random.seed(0) + mu, sigma = 0, 0.1 + return {"var": list(np.random.normal(mu, sigma, 100))} + + +def _discretise_data(): + np.random.seed(42) + mu1, sigma1 = 0, 3 + s1 = np.random.normal(mu1, sigma1, 20) + mu2, sigma2 = 3, 5 + s2 = np.random.normal(mu2, sigma2, 20) + return { + "var_A": list(s1), + "var_B": list(s2), + "target": [0, 1, 1, 0, 1, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 0, 1, 1, 1, 1], + } + + +def _unique_sorted(X, col): + # polars' Series.unique() doesn't preserve first-occurrence order like + # pandas does, so compare the resulting *set* of values, sorted, rather + # than relying on unique()'s order (which differs across backends). + return sorted(nw.from_native(X, eager_only=True).get_column(col).unique().to_list()) # init parameters @@ -28,17 +56,15 @@ def test_error_if_binoutput_not_permitted_value(bin_output_): "bin_output takes values 'prediction', 'bin_number' or 'boundaries'. " f"Got {bin_output_} instead." ) - with pytest.raises(ValueError) as record: + with pytest.raises(ValueError, match=msg): DecisionTreeDiscretiser(bin_output=bin_output_) - assert str(record.value) == msg @pytest.mark.parametrize("precision_", ["arbitrary", -1, 0.3]) def test_error_if_precision_not_permitted_value(precision_): msg = "precision must be None or a positive integer. " f"Got {precision_} instead." - with pytest.raises(ValueError) as record: + with pytest.raises(ValueError, match=msg): DecisionTreeDiscretiser(precision=precision_) - assert str(record.value) == msg def test_precision_errors_if_none_when_bin_output_is_boundaries(): @@ -46,9 +72,8 @@ def test_precision_errors_if_none_when_bin_output_is_boundaries(): "When `bin_output == 'boundaries', `precision` cannot be None. " "Change precision's value to a positive integer." ) - with pytest.raises(ValueError) as record: + with pytest.raises(ValueError, match=msg): DecisionTreeDiscretiser(precision=None, bin_output="boundaries") - assert str(record.value) == msg dsc = DecisionTreeDiscretiser(precision=None, bin_output="bin_number") assert dsc.precision is None @@ -57,31 +82,37 @@ def test_precision_errors_if_none_when_bin_output_is_boundaries(): @pytest.mark.parametrize("regression_", ["arbitrary", -1, 0.3]) def test_error_if_regression_is_not_bool(regression_): msg = "regression can only take True or False. " f"Got {regression_} instead." - with pytest.raises(ValueError) as record: + with pytest.raises(ValueError, match=msg): DecisionTreeDiscretiser(regression=regression_) - assert str(record.value) == msg # fit -def test_error_if_y_not_passed(df_normal_dist): +@pytest.mark.parametrize("make_df", [pd.DataFrame, pl.DataFrame]) +def test_error_if_y_not_passed(make_df): + X = make_df(_normal_dist_data()) encoder = DecisionTreeDiscretiser() with pytest.raises(TypeError): - encoder.fit(df_normal_dist) + encoder.fit(X) -def test_error_when_regression_is_true_and_target_is_binary(df_discretise): +@pytest.mark.parametrize("make_df", [pd.DataFrame, pl.DataFrame]) +def test_error_when_regression_is_true_and_target_is_binary(make_df): + data = _discretise_data() + X = make_df({"var_A": data["var_A"], "var_B": data["var_B"]}) + y = data["target"] msg = ( "Trying to fit a regression to a binary target is not " "allowed by this transformer. Check the target values " "or set regression to False." ) transformer = DecisionTreeDiscretiser(regression=True) - with pytest.raises(ValueError) as record: - transformer.fit(df_discretise[["var_A", "var_B"]], df_discretise["target"]) - assert str(record.value) == msg + with pytest.raises(ValueError, match=msg): + transformer.fit(X, y) -def test_classification_predictions(df_normal_dist): +@pytest.mark.parametrize("make_df", [pd.DataFrame, pl.DataFrame]) +def test_classification_predictions(make_df): + X = make_df(_normal_dist_data()) transformer = DecisionTreeDiscretiser( cv=3, @@ -92,8 +123,8 @@ def test_classification_predictions(df_normal_dist): random_state=0, ) np.random.seed(0) - y = pd.Series(np.random.binomial(1, 0.7, 100)) - X = transformer.fit_transform(df_normal_dist, y) + y = list(np.random.binomial(1, 0.7, 100)) + Xt = transformer.fit_transform(X, y) X_t = [1.0, 0.71, 0.93, 0.0] # init params @@ -105,12 +136,14 @@ def test_classification_predictions(df_normal_dist): assert transformer.variables_ == ["var"] assert transformer.n_features_in_ == 1 # transform params - assert all(x for x in np.round(X["var"].unique(), 2) if x not in X_t) + unique_vals = _unique_sorted(Xt, "var") + assert all(x for x in np.round(unique_vals, 2) if x not in X_t) assert np.round(transformer.scores_dict_["var"], 3) == np.round( 0.717391304347826, 3 ) +@pytest.mark.parametrize("make_df", [pd.DataFrame, pl.DataFrame]) @pytest.mark.parametrize( "params", [ @@ -119,7 +152,8 @@ def test_classification_predictions(df_normal_dist): (3, [1.0, 0.712, 0.933, 0.0]), ], ) -def test_classification_rounds_predictions(df_normal_dist, params): +def test_classification_rounds_predictions(make_df, params): + X = make_df(_normal_dist_data()) transformer = DecisionTreeDiscretiser( precision=params[0], @@ -131,14 +165,16 @@ def test_classification_rounds_predictions(df_normal_dist, params): random_state=0, ) np.random.seed(0) - y = pd.Series(np.random.binomial(1, 0.7, 100)) - X = transformer.fit_transform(df_normal_dist, y) + y = list(np.random.binomial(1, 0.7, 100)) + Xt = transformer.fit_transform(X, y) bins = params[1] - assert list(X["var"].unique()) == bins + assert _unique_sorted(Xt, "var") == sorted(bins) -def test_classification_bin_number(df_normal_dist): +@pytest.mark.parametrize("make_df", [pd.DataFrame, pl.DataFrame]) +def test_classification_bin_number(make_df): + X = make_df(_normal_dist_data()) transformer = DecisionTreeDiscretiser( bin_output="bin_number", scoring="roc_auc", @@ -147,9 +183,9 @@ def test_classification_bin_number(df_normal_dist): random_state=0, ) np.random.seed(0) - y = pd.Series(np.random.binomial(1, 0.7, 100)) - X = transformer.fit_transform(df_normal_dist, y) - bins = [4, 2, 1, 0, 3] + y = list(np.random.binomial(1, 0.7, 100)) + Xt = transformer.fit_transform(X, y) + bins = [0, 1, 2, 3, 4] limits = [ -np.inf, -0.22668930888175964, @@ -163,10 +199,12 @@ def test_classification_bin_number(df_normal_dist): assert np.round(transformer.scores_dict_["var"], 3) == np.round( 0.717391304347826, 3 ) - assert list(X["var"].unique()) == bins + assert _unique_sorted(Xt, "var") == bins -def test_classification_boundaries(df_normal_dist): +@pytest.mark.parametrize("make_df", [pd.DataFrame, pl.DataFrame]) +def test_classification_boundaries(make_df): + X = make_df(_normal_dist_data()) transformer = DecisionTreeDiscretiser( bin_output="boundaries", precision=3, @@ -176,15 +214,17 @@ def test_classification_boundaries(df_normal_dist): random_state=0, ) np.random.seed(0) - y = pd.Series(np.random.binomial(1, 0.7, 100)) - X = transformer.fit_transform(df_normal_dist, y) - bins = [ - "(0.116, inf]", - "(-0.0942, 0.102]", - "(-0.227, -0.0942]", - "(-inf, -0.227]", - "(0.102, 0.116]", - ] + y = list(np.random.binomial(1, 0.7, 100)) + Xt = transformer.fit_transform(X, y) + bins = sorted( + [ + "(0.116, inf]", + "(-0.0942, 0.102]", + "(-0.227, -0.0942]", + "(-inf, -0.227]", + "(0.102, 0.116]", + ] + ) limits = [ -np.inf, -0.22668930888175964, @@ -198,10 +238,12 @@ def test_classification_boundaries(df_normal_dist): assert np.round(transformer.scores_dict_["var"], 3) == np.round( 0.717391304347826, 3 ) - assert list(X["var"].unique()) == bins + assert _unique_sorted(Xt, "var") == bins -def test_regression(df_normal_dist): +@pytest.mark.parametrize("make_df", [pd.DataFrame, pl.DataFrame]) +def test_regression(make_df): + X = make_df(_normal_dist_data()) transformer = DecisionTreeDiscretiser( cv=3, @@ -212,8 +254,8 @@ def test_regression(df_normal_dist): random_state=0, ) np.random.seed(0) - y = pd.Series(pd.Series(np.random.normal(0, 0.1, 100))) - X = transformer.fit_transform(df_normal_dist, y) + y = list(np.random.normal(0, 0.1, 100)) + Xt = transformer.fit_transform(X, y) X_t = [ 0.19, 0.04, @@ -245,9 +287,11 @@ def test_regression(df_normal_dist): -4.4373314584616444e-05, 3 ) # transform params - assert all(x for x in np.round(X["var"].unique(), 2) if x not in X_t) + unique_vals = _unique_sorted(Xt, "var") + assert all(x for x in np.round(unique_vals, 2) if x not in X_t) +@pytest.mark.parametrize("make_df", [pd.DataFrame, pl.DataFrame]) @pytest.mark.parametrize( "params", [ @@ -275,7 +319,8 @@ def test_regression(df_normal_dist): ), ], ) -def test_regression_rounds_predictions(df_normal_dist, params): +def test_regression_rounds_predictions(make_df, params): + X = make_df(_normal_dist_data()) transformer = DecisionTreeDiscretiser( precision=params[0], @@ -287,42 +332,56 @@ def test_regression_rounds_predictions(df_normal_dist, params): random_state=0, ) np.random.seed(0) - y = pd.Series(pd.Series(np.random.normal(0, 0.1, 100))) - X = transformer.fit_transform(df_normal_dist, y) + y = list(np.random.normal(0, 0.1, 100)) + Xt = transformer.fit_transform(X, y) bins = params[1] - assert list(X["var"].unique()) == bins + assert _unique_sorted(Xt, "var") == sorted(bins) # transform -def test_non_fitted_error(df_vartypes): +@pytest.mark.parametrize("make_df", [pd.DataFrame, pl.DataFrame]) +def test_non_fitted_error(make_df): + X = make_df(_normal_dist_data()) with pytest.raises(NotFittedError): - transformer = EqualWidthDiscretiser() - transformer.transform(df_vartypes) + transformer = DecisionTreeDiscretiser() + transformer.transform(X) -@pytest.fixture(scope="module") -def df_discretise(): +@pytest.mark.parametrize("make_df", [pd.DataFrame, pl.DataFrame]) +def test_error_when_regression_is_false_and_target_is_continuous(make_df): + data = _discretise_data() + X = make_df({"var_A": data["var_A"], "var_B": data["var_B"]}) np.random.seed(42) - mu1, sigma1 = 0, 3 - s1 = np.random.normal(mu1, sigma1, 20) - mu2, sigma2 = 3, 5 - s2 = np.random.normal(mu2, sigma2, 20) - data = { - "var_A": s1, - "var_B": s2, - "target": [0, 1, 1, 0, 1, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 0, 1, 1, 1, 1], - } + mu, sigma = 0, 3 + y = list(np.random.normal(mu, sigma, len(data["var_A"]))) + transformer = DecisionTreeDiscretiser(regression=False) + with pytest.raises(ValueError): + transformer.fit(X, y) - df = pd.DataFrame(data) - return df +@pytest.mark.parametrize("make_df", [pd.DataFrame, pl.DataFrame]) +def test_n_jobs_parallel_matches_sequential(make_df): + # core correctness check for n_jobs: parallelizing tree training across + # variables must produce identical trees, and therefore identical + # predictions, to sequential training (n_jobs=None). + data = _discretise_data() + X = make_df({"var_A": data["var_A"], "var_B": data["var_B"]}) + np.random.seed(0) + y = list(np.random.normal(0, 1, len(data["var_A"]))) + tr_seq = DecisionTreeDiscretiser( + n_jobs=None, random_state=0, param_grid={"max_depth": [1, 2, 3]} + ) + tr_seq.fit(X, y) + tr_par = DecisionTreeDiscretiser( + n_jobs=2, random_state=0, param_grid={"max_depth": [1, 2, 3]} + ) + tr_par.fit(X, y) -def test_error_when_regression_is_false_and_target_is_continuous(df_discretise): - np.random.seed(42) - mu, sigma = 0, 3 - y = np.random.normal(mu, sigma, len(df_discretise)) - transformer = DecisionTreeDiscretiser(regression=False) - with pytest.raises(ValueError): - transformer.fit(df_discretise[["var_A", "var_B"]], y) + Xt_seq = tr_seq.transform(X) + Xt_par = tr_par.transform(X) + + expected = nw.from_native(Xt_seq, eager_only=True).to_dict(as_series=False) + result = nw.from_native(Xt_par, eager_only=True).to_dict(as_series=False) + assert result == expected From 8fbee37db5d9b45383709b51711fd82b5c5c8c4f Mon Sep 17 00:00:00 2001 From: Soledad Galli Date: Mon, 14 Sep 2026 22:43:56 +0200 Subject: [PATCH 02/10] Use shared backend test fixtures and helpers in DecisionTreeDiscretiser tests Replace the file-local _normal_dist_data/_discretise_data/_unique_sorted helpers with the shared test structure: data_normal_dist fixture, y built with make_series on the backend under test, isinstance(X, make_df) plus to_dict() checks, and pytest.raises(match=re.escape(msg)). Add a test passing the target as a list and as a numpy array, which must give the same result as a Series. Co-Authored-By: Claude Opus 5 --- .../test_decision_tree_discretiser.py | 178 +++++++++--------- 1 file changed, 86 insertions(+), 92 deletions(-) diff --git a/tests/test_discretisation/test_decision_tree_discretiser.py b/tests/test_discretisation/test_decision_tree_discretiser.py index 2e7f222ac..42517a47a 100644 --- a/tests/test_discretisation/test_decision_tree_discretiser.py +++ b/tests/test_discretisation/test_decision_tree_discretiser.py @@ -1,37 +1,28 @@ -import narwhals as nw +import re + import numpy as np -import pandas as pd -import polars as pl import pytest from sklearn.exceptions import NotFittedError from feature_engine.discretisation import DecisionTreeDiscretiser +from tests.backend_helpers import make_series, to_dict - -def _normal_dist_data(): - np.random.seed(0) - mu, sigma = 0, 0.1 - return {"var": list(np.random.normal(mu, sigma, 100))} +_rng = np.random.RandomState(42) +DATA_TWO_VARS = { + "var_A": _rng.normal(0, 3, 20).tolist(), + "var_B": _rng.normal(3, 5, 20).tolist(), +} +TARGET_TWO_VARS = [0, 1, 1, 0, 1, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 0, 1, 1, 1, 1] -def _discretise_data(): - np.random.seed(42) - mu1, sigma1 = 0, 3 - s1 = np.random.normal(mu1, sigma1, 20) - mu2, sigma2 = 3, 5 - s2 = np.random.normal(mu2, sigma2, 20) - return { - "var_A": list(s1), - "var_B": list(s2), - "target": [0, 1, 1, 0, 1, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 0, 1, 1, 1, 1], - } +def _binary_target(): + np.random.seed(0) + return np.random.binomial(1, 0.7, 100).tolist() -def _unique_sorted(X, col): - # polars' Series.unique() doesn't preserve first-occurrence order like - # pandas does, so compare the resulting *set* of values, sorted, rather - # than relying on unique()'s order (which differs across backends). - return sorted(nw.from_native(X, eager_only=True).get_column(col).unique().to_list()) +def _continuous_target(): + np.random.seed(0) + return np.random.normal(0, 0.1, 100).tolist() # init parameters @@ -56,14 +47,14 @@ def test_error_if_binoutput_not_permitted_value(bin_output_): "bin_output takes values 'prediction', 'bin_number' or 'boundaries'. " f"Got {bin_output_} instead." ) - with pytest.raises(ValueError, match=msg): + with pytest.raises(ValueError, match=re.escape(msg)): DecisionTreeDiscretiser(bin_output=bin_output_) @pytest.mark.parametrize("precision_", ["arbitrary", -1, 0.3]) def test_error_if_precision_not_permitted_value(precision_): msg = "precision must be None or a positive integer. " f"Got {precision_} instead." - with pytest.raises(ValueError, match=msg): + with pytest.raises(ValueError, match=re.escape(msg)): DecisionTreeDiscretiser(precision=precision_) @@ -72,7 +63,7 @@ def test_precision_errors_if_none_when_bin_output_is_boundaries(): "When `bin_output == 'boundaries', `precision` cannot be None. " "Change precision's value to a positive integer." ) - with pytest.raises(ValueError, match=msg): + with pytest.raises(ValueError, match=re.escape(msg)): DecisionTreeDiscretiser(precision=None, bin_output="boundaries") dsc = DecisionTreeDiscretiser(precision=None, bin_output="bin_number") @@ -82,37 +73,33 @@ def test_precision_errors_if_none_when_bin_output_is_boundaries(): @pytest.mark.parametrize("regression_", ["arbitrary", -1, 0.3]) def test_error_if_regression_is_not_bool(regression_): msg = "regression can only take True or False. " f"Got {regression_} instead." - with pytest.raises(ValueError, match=msg): + with pytest.raises(ValueError, match=re.escape(msg)): DecisionTreeDiscretiser(regression=regression_) # fit -@pytest.mark.parametrize("make_df", [pd.DataFrame, pl.DataFrame]) -def test_error_if_y_not_passed(make_df): - X = make_df(_normal_dist_data()) +def test_error_if_y_not_passed(make_df, data_normal_dist): encoder = DecisionTreeDiscretiser() with pytest.raises(TypeError): - encoder.fit(X) + encoder.fit(make_df(data_normal_dist)) -@pytest.mark.parametrize("make_df", [pd.DataFrame, pl.DataFrame]) def test_error_when_regression_is_true_and_target_is_binary(make_df): - data = _discretise_data() - X = make_df({"var_A": data["var_A"], "var_B": data["var_B"]}) - y = data["target"] + X = make_df(DATA_TWO_VARS) + y = make_series(make_df, TARGET_TWO_VARS) msg = ( "Trying to fit a regression to a binary target is not " "allowed by this transformer. Check the target values " "or set regression to False." ) transformer = DecisionTreeDiscretiser(regression=True) - with pytest.raises(ValueError, match=msg): + with pytest.raises(ValueError, match=re.escape(msg)): transformer.fit(X, y) -@pytest.mark.parametrize("make_df", [pd.DataFrame, pl.DataFrame]) -def test_classification_predictions(make_df): - X = make_df(_normal_dist_data()) +def test_classification_predictions(make_df, data_normal_dist): + X = make_df(data_normal_dist) + y = make_series(make_df, _binary_target()) transformer = DecisionTreeDiscretiser( cv=3, @@ -122,8 +109,6 @@ def test_classification_predictions(make_df): regression=False, random_state=0, ) - np.random.seed(0) - y = list(np.random.binomial(1, 0.7, 100)) Xt = transformer.fit_transform(X, y) X_t = [1.0, 0.71, 0.93, 0.0] @@ -136,14 +121,37 @@ def test_classification_predictions(make_df): assert transformer.variables_ == ["var"] assert transformer.n_features_in_ == 1 # transform params - unique_vals = _unique_sorted(Xt, "var") + assert isinstance(Xt, make_df) + unique_vals = sorted(set(to_dict(Xt)["var"])) assert all(x for x in np.round(unique_vals, 2) if x not in X_t) assert np.round(transformer.scores_dict_["var"], 3) == np.round( 0.717391304347826, 3 ) -@pytest.mark.parametrize("make_df", [pd.DataFrame, pl.DataFrame]) +@pytest.mark.parametrize("to_target", [list, np.array]) +def test_target_as_list_or_array(make_df, data_normal_dist, to_target): + # a list or numpy array target must give the same result as a Series + X = make_df(data_normal_dist) + params = dict( + bin_output="bin_number", + scoring="roc_auc", + param_grid={"max_depth": [1, 2, 3, 4]}, + regression=False, + random_state=0, + ) + + from_series = DecisionTreeDiscretiser(**params) + from_series.fit(X, make_series(make_df, _binary_target())) + transformer = DecisionTreeDiscretiser(**params) + transformer.fit(X, to_target(_binary_target())) + Xt = transformer.transform(X) + + assert transformer.binner_dict_ == from_series.binner_dict_ + assert isinstance(Xt, make_df) + assert to_dict(Xt) == to_dict(from_series.transform(X)) + + @pytest.mark.parametrize( "params", [ @@ -152,8 +160,9 @@ def test_classification_predictions(make_df): (3, [1.0, 0.712, 0.933, 0.0]), ], ) -def test_classification_rounds_predictions(make_df, params): - X = make_df(_normal_dist_data()) +def test_classification_rounds_predictions(make_df, data_normal_dist, params): + X = make_df(data_normal_dist) + y = make_series(make_df, _binary_target()) transformer = DecisionTreeDiscretiser( precision=params[0], @@ -164,17 +173,15 @@ def test_classification_rounds_predictions(make_df, params): regression=False, random_state=0, ) - np.random.seed(0) - y = list(np.random.binomial(1, 0.7, 100)) Xt = transformer.fit_transform(X, y) - bins = params[1] - assert _unique_sorted(Xt, "var") == sorted(bins) + assert isinstance(Xt, make_df) + assert sorted(set(to_dict(Xt)["var"])) == sorted(params[1]) -@pytest.mark.parametrize("make_df", [pd.DataFrame, pl.DataFrame]) -def test_classification_bin_number(make_df): - X = make_df(_normal_dist_data()) +def test_classification_bin_number(make_df, data_normal_dist): + X = make_df(data_normal_dist) + y = make_series(make_df, _binary_target()) transformer = DecisionTreeDiscretiser( bin_output="bin_number", scoring="roc_auc", @@ -182,8 +189,6 @@ def test_classification_bin_number(make_df): regression=False, random_state=0, ) - np.random.seed(0) - y = list(np.random.binomial(1, 0.7, 100)) Xt = transformer.fit_transform(X, y) bins = [0, 1, 2, 3, 4] limits = [ @@ -199,12 +204,13 @@ def test_classification_bin_number(make_df): assert np.round(transformer.scores_dict_["var"], 3) == np.round( 0.717391304347826, 3 ) - assert _unique_sorted(Xt, "var") == bins + assert isinstance(Xt, make_df) + assert sorted(set(to_dict(Xt)["var"])) == bins -@pytest.mark.parametrize("make_df", [pd.DataFrame, pl.DataFrame]) -def test_classification_boundaries(make_df): - X = make_df(_normal_dist_data()) +def test_classification_boundaries(make_df, data_normal_dist): + X = make_df(data_normal_dist) + y = make_series(make_df, _binary_target()) transformer = DecisionTreeDiscretiser( bin_output="boundaries", precision=3, @@ -213,8 +219,6 @@ def test_classification_boundaries(make_df): regression=False, random_state=0, ) - np.random.seed(0) - y = list(np.random.binomial(1, 0.7, 100)) Xt = transformer.fit_transform(X, y) bins = sorted( [ @@ -238,12 +242,13 @@ def test_classification_boundaries(make_df): assert np.round(transformer.scores_dict_["var"], 3) == np.round( 0.717391304347826, 3 ) - assert _unique_sorted(Xt, "var") == bins + assert isinstance(Xt, make_df) + assert sorted(set(to_dict(Xt)["var"])) == bins -@pytest.mark.parametrize("make_df", [pd.DataFrame, pl.DataFrame]) -def test_regression(make_df): - X = make_df(_normal_dist_data()) +def test_regression(make_df, data_normal_dist): + X = make_df(data_normal_dist) + y = make_series(make_df, _continuous_target()) transformer = DecisionTreeDiscretiser( cv=3, @@ -253,8 +258,6 @@ def test_regression(make_df): regression=True, random_state=0, ) - np.random.seed(0) - y = list(np.random.normal(0, 0.1, 100)) Xt = transformer.fit_transform(X, y) X_t = [ 0.19, @@ -287,11 +290,11 @@ def test_regression(make_df): -4.4373314584616444e-05, 3 ) # transform params - unique_vals = _unique_sorted(Xt, "var") + assert isinstance(Xt, make_df) + unique_vals = sorted(set(to_dict(Xt)["var"])) assert all(x for x in np.round(unique_vals, 2) if x not in X_t) -@pytest.mark.parametrize("make_df", [pd.DataFrame, pl.DataFrame]) @pytest.mark.parametrize( "params", [ @@ -319,8 +322,9 @@ def test_regression(make_df): ), ], ) -def test_regression_rounds_predictions(make_df, params): - X = make_df(_normal_dist_data()) +def test_regression_rounds_predictions(make_df, data_normal_dist, params): + X = make_df(data_normal_dist) + y = make_series(make_df, _continuous_target()) transformer = DecisionTreeDiscretiser( precision=params[0], @@ -331,44 +335,35 @@ def test_regression_rounds_predictions(make_df, params): regression=True, random_state=0, ) - np.random.seed(0) - y = list(np.random.normal(0, 0.1, 100)) Xt = transformer.fit_transform(X, y) - bins = params[1] - assert _unique_sorted(Xt, "var") == sorted(bins) + assert isinstance(Xt, make_df) + assert sorted(set(to_dict(Xt)["var"])) == sorted(params[1]) # transform -@pytest.mark.parametrize("make_df", [pd.DataFrame, pl.DataFrame]) -def test_non_fitted_error(make_df): - X = make_df(_normal_dist_data()) +def test_non_fitted_error(make_df, data_normal_dist): + transformer = DecisionTreeDiscretiser() with pytest.raises(NotFittedError): - transformer = DecisionTreeDiscretiser() - transformer.transform(X) + transformer.transform(make_df(data_normal_dist)) -@pytest.mark.parametrize("make_df", [pd.DataFrame, pl.DataFrame]) def test_error_when_regression_is_false_and_target_is_continuous(make_df): - data = _discretise_data() - X = make_df({"var_A": data["var_A"], "var_B": data["var_B"]}) + X = make_df(DATA_TWO_VARS) np.random.seed(42) - mu, sigma = 0, 3 - y = list(np.random.normal(mu, sigma, len(data["var_A"]))) + y = make_series(make_df, np.random.normal(0, 3, 20).tolist()) transformer = DecisionTreeDiscretiser(regression=False) with pytest.raises(ValueError): transformer.fit(X, y) -@pytest.mark.parametrize("make_df", [pd.DataFrame, pl.DataFrame]) def test_n_jobs_parallel_matches_sequential(make_df): # core correctness check for n_jobs: parallelizing tree training across # variables must produce identical trees, and therefore identical # predictions, to sequential training (n_jobs=None). - data = _discretise_data() - X = make_df({"var_A": data["var_A"], "var_B": data["var_B"]}) + X = make_df(DATA_TWO_VARS) np.random.seed(0) - y = list(np.random.normal(0, 1, len(data["var_A"]))) + y = make_series(make_df, np.random.normal(0, 1, 20).tolist()) tr_seq = DecisionTreeDiscretiser( n_jobs=None, random_state=0, param_grid={"max_depth": [1, 2, 3]} @@ -382,6 +377,5 @@ def test_n_jobs_parallel_matches_sequential(make_df): Xt_seq = tr_seq.transform(X) Xt_par = tr_par.transform(X) - expected = nw.from_native(Xt_seq, eager_only=True).to_dict(as_series=False) - result = nw.from_native(Xt_par, eager_only=True).to_dict(as_series=False) - assert result == expected + assert isinstance(Xt_par, make_df) + assert to_dict(Xt_par) == to_dict(Xt_seq) From f8b079a4684e0a893a36804b15cffc911546d439 Mon Sep 17 00:00:00 2001 From: Soledad Galli Date: Tue, 15 Sep 2026 12:06:30 +0200 Subject: [PATCH 03/10] Use frame_to_dict after the shared helper rename in #1045 Co-Authored-By: Claude Opus 5 --- .../test_decision_tree_discretiser.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/tests/test_discretisation/test_decision_tree_discretiser.py b/tests/test_discretisation/test_decision_tree_discretiser.py index 42517a47a..347c99dfd 100644 --- a/tests/test_discretisation/test_decision_tree_discretiser.py +++ b/tests/test_discretisation/test_decision_tree_discretiser.py @@ -5,7 +5,7 @@ from sklearn.exceptions import NotFittedError from feature_engine.discretisation import DecisionTreeDiscretiser -from tests.backend_helpers import make_series, to_dict +from tests.backend_helpers import make_series, frame_to_dict _rng = np.random.RandomState(42) DATA_TWO_VARS = { @@ -122,7 +122,7 @@ def test_classification_predictions(make_df, data_normal_dist): assert transformer.n_features_in_ == 1 # transform params assert isinstance(Xt, make_df) - unique_vals = sorted(set(to_dict(Xt)["var"])) + unique_vals = sorted(set(frame_to_dict(Xt)["var"])) assert all(x for x in np.round(unique_vals, 2) if x not in X_t) assert np.round(transformer.scores_dict_["var"], 3) == np.round( 0.717391304347826, 3 @@ -149,7 +149,7 @@ def test_target_as_list_or_array(make_df, data_normal_dist, to_target): assert transformer.binner_dict_ == from_series.binner_dict_ assert isinstance(Xt, make_df) - assert to_dict(Xt) == to_dict(from_series.transform(X)) + assert frame_to_dict(Xt) == frame_to_dict(from_series.transform(X)) @pytest.mark.parametrize( @@ -176,7 +176,7 @@ def test_classification_rounds_predictions(make_df, data_normal_dist, params): Xt = transformer.fit_transform(X, y) assert isinstance(Xt, make_df) - assert sorted(set(to_dict(Xt)["var"])) == sorted(params[1]) + assert sorted(set(frame_to_dict(Xt)["var"])) == sorted(params[1]) def test_classification_bin_number(make_df, data_normal_dist): @@ -205,7 +205,7 @@ def test_classification_bin_number(make_df, data_normal_dist): 0.717391304347826, 3 ) assert isinstance(Xt, make_df) - assert sorted(set(to_dict(Xt)["var"])) == bins + assert sorted(set(frame_to_dict(Xt)["var"])) == bins def test_classification_boundaries(make_df, data_normal_dist): @@ -243,7 +243,7 @@ def test_classification_boundaries(make_df, data_normal_dist): 0.717391304347826, 3 ) assert isinstance(Xt, make_df) - assert sorted(set(to_dict(Xt)["var"])) == bins + assert sorted(set(frame_to_dict(Xt)["var"])) == bins def test_regression(make_df, data_normal_dist): @@ -291,7 +291,7 @@ def test_regression(make_df, data_normal_dist): ) # transform params assert isinstance(Xt, make_df) - unique_vals = sorted(set(to_dict(Xt)["var"])) + unique_vals = sorted(set(frame_to_dict(Xt)["var"])) assert all(x for x in np.round(unique_vals, 2) if x not in X_t) @@ -338,7 +338,7 @@ def test_regression_rounds_predictions(make_df, data_normal_dist, params): Xt = transformer.fit_transform(X, y) assert isinstance(Xt, make_df) - assert sorted(set(to_dict(Xt)["var"])) == sorted(params[1]) + assert sorted(set(frame_to_dict(Xt)["var"])) == sorted(params[1]) # transform @@ -378,4 +378,4 @@ def test_n_jobs_parallel_matches_sequential(make_df): Xt_par = tr_par.transform(X) assert isinstance(Xt_par, make_df) - assert to_dict(Xt_par) == to_dict(Xt_seq) + assert frame_to_dict(Xt_par) == frame_to_dict(Xt_seq) From c3a7ffbbf03e9cb6c84ce4db3512dff0057ea9a9 Mon Sep 17 00:00:00 2001 From: Soledad Galli Date: Fri, 18 Sep 2026 13:24:32 +0200 Subject: [PATCH 04/10] Group init tests and match errors in DecisionTreeDiscretiser tests, check bin_output type Co-Authored-By: Claude Opus 5 --- .../discretisation/decision_tree.py | 6 +- .../test_decision_tree_discretiser.py | 77 ++++++++++++------- 2 files changed, 56 insertions(+), 27 deletions(-) diff --git a/feature_engine/discretisation/decision_tree.py b/feature_engine/discretisation/decision_tree.py index f49e51bb8..121e49915 100644 --- a/feature_engine/discretisation/decision_tree.py +++ b/feature_engine/discretisation/decision_tree.py @@ -275,7 +275,11 @@ def __init__( n_jobs: Optional[int] = None, ) -> None: - if bin_output not in ["prediction", "bin_number", "boundaries"]: + if not isinstance(bin_output, str) or bin_output not in [ + "prediction", + "bin_number", + "boundaries", + ]: raise ValueError( "bin_output takes values 'prediction', 'bin_number' or 'boundaries'. " f"Got {bin_output} instead." diff --git a/tests/test_discretisation/test_decision_tree_discretiser.py b/tests/test_discretisation/test_decision_tree_discretiser.py index 347c99dfd..a6fa80feb 100644 --- a/tests/test_discretisation/test_decision_tree_discretiser.py +++ b/tests/test_discretisation/test_decision_tree_discretiser.py @@ -28,20 +28,48 @@ def _continuous_target(): # init parameters @pytest.mark.parametrize( "params", - [("prediction", 3, True), ("bin_number", 10, False), ("boundaries", 1, False)], + [ + { + "bin_output": "prediction", + "precision": 3, + "cv": 3, + "scoring": "neg_mean_squared_error", + "param_grid": None, + "regression": True, + "random_state": None, + "n_jobs": None, + }, + { + "bin_output": "bin_number", + "precision": None, + "cv": 5, + "scoring": "roc_auc", + "param_grid": {"max_depth": [1, 2]}, + "regression": False, + "random_state": 0, + "n_jobs": -1, + }, + { + "bin_output": "boundaries", + "precision": 1, + "cv": 2, + "scoring": "accuracy", + "param_grid": {"max_depth": [3]}, + "regression": False, + "random_state": 42, + "n_jobs": 2, + }, + ], ) def test_init_param_assignment(params): - dsc = DecisionTreeDiscretiser( - bin_output=params[0], - precision=params[1], - regression=params[2], - ) - assert dsc.bin_output == params[0] - assert dsc.precision == params[1] - assert dsc.regression == params[2] + transformer = DecisionTreeDiscretiser(**params) + for param, value in params.items(): + assert getattr(transformer, param) == value -@pytest.mark.parametrize("bin_output_", ["arbitrary", False, 1]) +@pytest.mark.parametrize( + "bin_output_", ["arbitrary", "Prediction", "", False, 1, None, ["prediction"]] +) def test_error_if_binoutput_not_permitted_value(bin_output_): msg = ( "bin_output takes values 'prediction', 'bin_number' or 'boundaries'. " @@ -80,7 +108,8 @@ def test_error_if_regression_is_not_bool(regression_): # fit def test_error_if_y_not_passed(make_df, data_normal_dist): encoder = DecisionTreeDiscretiser() - with pytest.raises(TypeError): + msg = "DecisionTreeDiscretiser.fit() missing 1 required positional argument: 'y'" + with pytest.raises(TypeError, match=re.escape(msg)): encoder.fit(make_df(data_normal_dist)) @@ -112,11 +141,6 @@ def test_classification_predictions(make_df, data_normal_dist): Xt = transformer.fit_transform(X, y) X_t = [1.0, 0.71, 0.93, 0.0] - # init params - assert transformer.cv == 3 - assert transformer.variables is None - assert transformer.scoring == "roc_auc" - assert transformer.regression is False # fit params assert transformer.variables_ == ["var"] assert transformer.n_features_in_ == 1 @@ -278,11 +302,6 @@ def test_regression(make_df, data_normal_dist): -0.12, ] - # init params - assert transformer.cv == 3 - assert transformer.variables is None - assert transformer.scoring == "neg_mean_squared_error" - assert transformer.regression is True # fit params assert transformer.variables_ == ["var"] assert transformer.n_features_in_ == 1 @@ -344,7 +363,11 @@ def test_regression_rounds_predictions(make_df, data_normal_dist, params): # transform def test_non_fitted_error(make_df, data_normal_dist): transformer = DecisionTreeDiscretiser() - with pytest.raises(NotFittedError): + msg = ( + "This DecisionTreeDiscretiser 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_normal_dist)) @@ -353,14 +376,16 @@ def test_error_when_regression_is_false_and_target_is_continuous(make_df): np.random.seed(42) y = make_series(make_df, np.random.normal(0, 3, 20).tolist()) transformer = DecisionTreeDiscretiser(regression=False) - with pytest.raises(ValueError): + msg = ( + "Unknown label type: continuous. Maybe you are trying to fit a classifier, " + "which expects discrete classes on a regression target with continuous values." + ) + with pytest.raises(ValueError, match=re.escape(msg)): transformer.fit(X, y) def test_n_jobs_parallel_matches_sequential(make_df): - # core correctness check for n_jobs: parallelizing tree training across - # variables must produce identical trees, and therefore identical - # predictions, to sequential training (n_jobs=None). + # parallel tree training must give the same trees and predictions as sequential X = make_df(DATA_TWO_VARS) np.random.seed(0) y = make_series(make_df, np.random.normal(0, 1, 20).tolist()) From fc12998cfed2852df0787da6b74f8c2f5ba91a6f Mon Sep 17 00:00:00 2001 From: Soledad Galli Date: Fri, 18 Sep 2026 13:56:52 +0200 Subject: [PATCH 05/10] Move DecisionTreeDiscretiser helper functions into the class as private methods Co-Authored-By: Claude Opus 5 --- .../discretisation/decision_tree.py | 105 +++++++++--------- 1 file changed, 50 insertions(+), 55 deletions(-) diff --git a/feature_engine/discretisation/decision_tree.py b/feature_engine/discretisation/decision_tree.py index 121e49915..227a750e6 100644 --- a/feature_engine/discretisation/decision_tree.py +++ b/feature_engine/discretisation/decision_tree.py @@ -33,58 +33,6 @@ from feature_engine.tags import _return_tags -def _round_bin_edge(x: float, precision: int) -> float: - """Round a bin edge the way pandas.cut historically formatted Interval labels: - -inf/inf/0 pass through unrounded, and numbers with magnitude < 1 get extra - decimals so that `precision` significant digits survive past the leading - zeros (e.g. -0.0942 at precision=3 keeps 4 decimals, not 3, since - round(-0.0942, 3) == -0.094 would only keep 2 significant digits). - """ - if not np.isfinite(x) or x == 0: - return x - frac, whole = np.modf(x) - if whole == 0: - digits = -int(np.floor(np.log10(abs(frac)))) - 1 + precision - else: - digits = precision - return round(x, digits) - - -def _infer_bin_precision(thresholds: List[float], precision: int) -> int: - """Find the smallest precision >= `precision` at which every rounded - threshold is still distinct, mirroring pandas.cut's behaviour of bumping - precision (for every edge, not just the colliding pair) when the requested - precision would make two adjacent bin edges collide.""" - for prec in range(precision, 20): - rounded = [_round_bin_edge(t, prec) for t in thresholds] - if len(set(rounded)) == len(thresholds): - return prec - return precision - - -def _format_bin_edge(x: float, precision: int) -> str: - if x == -np.inf: - return "-inf" - if x == np.inf: - return "inf" - return str(_round_bin_edge(x, precision)) - - -def _bin_labels(thresholds: List[float], precision: int) -> List[str]: - """Build the `(left, right]` interval label for every bin delimited by - `thresholds`, which starts with -inf and ends with inf.""" - precision = _infer_bin_precision(thresholds, precision) - edges = [_format_bin_edge(t, precision) for t in thresholds] - return [f"({edges[i]}, {edges[i + 1]}]" for i in range(len(edges) - 1)] - - -def _bin_index(values: np.ndarray, thresholds: List[float]) -> np.ndarray: - """Map each value to the 0-indexed bin delimited by `thresholds` (which - starts with -inf and ends with inf), bins being closed on the right. - """ - return np.digitize(values, thresholds[1:-1], right=True) - - @Substitution( variables=_variables_numerical_docstring, variables_=_variables_attribute_docstring, @@ -423,16 +371,16 @@ def transform(self, X: IntoDataFrame) -> IntoDataFrame: assert self.precision is not None for feature in self.variables_: thresholds = self.binner_dict_[feature] - labels = _bin_labels(thresholds, self.precision) + labels = self._bin_labels(thresholds, self.precision) values = nw_X.get_column(feature).to_numpy() - bin_idx = _bin_index(values, thresholds) + bin_idx = self._bin_index(values, thresholds) new_columns[feature] = np.array(labels)[bin_idx] else: for feature in self.variables_: thresholds = self.binner_dict_[feature] values = nw_X.get_column(feature).to_numpy() - new_columns[feature] = _bin_index(values, thresholds) + new_columns[feature] = self._bin_index(values, thresholds) if is_pandas is True: X = X.assign(**new_columns) @@ -467,3 +415,50 @@ def _more_tags(self): def __sklearn_tags__(self): tags = super().__sklearn_tags__() return tags + + def _round_bin_edge(self, x: float, precision: int) -> float: + """Round a bin edge the way pandas.cut historically formatted Interval labels: + -inf/inf/0 pass through unrounded, and numbers with magnitude < 1 get extra + decimals so that `precision` significant digits survive past the leading + zeros (e.g. -0.0942 at precision=3 keeps 4 decimals, not 3, since + round(-0.0942, 3) == -0.094 would only keep 2 significant digits). + """ + if not np.isfinite(x) or x == 0: + return x + frac, whole = np.modf(x) + if whole == 0: + digits = -int(np.floor(np.log10(abs(frac)))) - 1 + precision + else: + digits = precision + return round(x, digits) + + def _infer_bin_precision(self, thresholds: List[float], precision: int) -> int: + """Find the smallest precision >= `precision` at which every rounded + threshold is still distinct, mirroring pandas.cut's behaviour of bumping + precision (for every edge, not just the colliding pair) when the requested + precision would make two adjacent bin edges collide.""" + for prec in range(precision, 20): + rounded = [self._round_bin_edge(t, prec) for t in thresholds] + if len(set(rounded)) == len(thresholds): + return prec + return precision + + def _format_bin_edge(self, x: float, precision: int) -> str: + if x == -np.inf: + return "-inf" + if x == np.inf: + return "inf" + return str(self._round_bin_edge(x, precision)) + + def _bin_labels(self, thresholds: List[float], precision: int) -> List[str]: + """Build the `(left, right]` interval label for every bin delimited by + `thresholds`, which starts with -inf and ends with inf.""" + precision = self._infer_bin_precision(thresholds, precision) + edges = [self._format_bin_edge(t, precision) for t in thresholds] + return [f"({edges[i]}, {edges[i + 1]}]" for i in range(len(edges) - 1)] + + def _bin_index(self, values: np.ndarray, thresholds: List[float]) -> np.ndarray: + """Map each value to the 0-indexed bin delimited by `thresholds` (which + starts with -inf and ends with inf), bins being closed on the right. + """ + return np.digitize(values, thresholds[1:-1], right=True) From 0afb0d221e7a584c1aa216cd315b13dd8bd810a4 Mon Sep 17 00:00:00 2001 From: Soledad Galli Date: Fri, 18 Sep 2026 14:10:33 +0200 Subject: [PATCH 06/10] Simplify the n_jobs note in the DecisionTreeDiscretiser user guide Co-Authored-By: Claude Opus 5 --- .../discretisation/DecisionTreeDiscretiser.rst | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/docs/user_guide/discretisation/DecisionTreeDiscretiser.rst b/docs/user_guide/discretisation/DecisionTreeDiscretiser.rst index 233f9ae97..9d012bb12 100644 --- a/docs/user_guide/discretisation/DecisionTreeDiscretiser.rst +++ b/docs/user_guide/discretisation/DecisionTreeDiscretiser.rst @@ -521,12 +521,10 @@ can be parallelized across variables with the `n_jobs` parameter: 8 4.533333 4.133333 4.133333 9 6.000000 6.950000 6.700000 -`n_jobs` defaults to `None`, which trains the trees sequentially, matching this transformer's -original behaviour. Setting it trains multiple trees at the same time using threads, which only -pays off once there are enough variables or a large enough `param_grid` to outweigh the overhead -of dispatching work to threads — with just a handful of variables, sequential training is faster. -The resulting trees and predictions are identical regardless of `n_jobs`; only training speed -changes. +`n_jobs` sets how many trees are trained at the same time. By default (`None`), the trees are +trained one after the other; `n_jobs=-1` uses all available processors. Training in parallel +helps when there are many variables or a large `param_grid`; with only a few variables, it may +not be faster. The results are the same whatever the value of `n_jobs`. Additional considerations ------------------------- From c701ca47dcede05b4091730cc01ed920cccca197 Mon Sep 17 00:00:00 2001 From: Soledad Galli Date: Fri, 18 Sep 2026 14:11:09 +0200 Subject: [PATCH 07/10] Use the scikit-learn wording for n_jobs in DecisionTreeDiscretiser Co-Authored-By: Claude Opus 5 --- .../discretisation/DecisionTreeDiscretiser.rst | 7 +++---- feature_engine/discretisation/decision_tree.py | 10 +++------- 2 files changed, 6 insertions(+), 11 deletions(-) diff --git a/docs/user_guide/discretisation/DecisionTreeDiscretiser.rst b/docs/user_guide/discretisation/DecisionTreeDiscretiser.rst index 9d012bb12..c15d4e418 100644 --- a/docs/user_guide/discretisation/DecisionTreeDiscretiser.rst +++ b/docs/user_guide/discretisation/DecisionTreeDiscretiser.rst @@ -521,10 +521,9 @@ can be parallelized across variables with the `n_jobs` parameter: 8 4.533333 4.133333 4.133333 9 6.000000 6.950000 6.700000 -`n_jobs` sets how many trees are trained at the same time. By default (`None`), the trees are -trained one after the other; `n_jobs=-1` uses all available processors. Training in parallel -helps when there are many variables or a large `param_grid`; with only a few variables, it may -not be faster. The results are the same whatever the value of `n_jobs`. +`n_jobs` is the number of jobs to run in parallel. `fit` is parallelized over the variables, +training one decision tree per variable. `None` means 1 unless in a `joblib.parallel_backend` +context. `-1` means using all processors. Additional considerations ------------------------- diff --git a/feature_engine/discretisation/decision_tree.py b/feature_engine/discretisation/decision_tree.py index 227a750e6..c3b0236f0 100644 --- a/feature_engine/discretisation/decision_tree.py +++ b/feature_engine/discretisation/decision_tree.py @@ -119,13 +119,9 @@ class DecisionTreeDiscretiser(BaseNumericalTransformer): the random_state to an integer. n_jobs: int, default=None - The number of jobs to run in parallel when training the decision trees - across variables. Trees are fit using threads rather than processes, - since fitting a decision tree releases the GIL for the bulk of its - computation, which avoids the overhead of copying the entire dataframe - to separate worker processes. `None` means 1, i.e. sequential training - (this transformer's original behaviour); `-1` means using all available - processors. + The number of jobs to run in parallel. `fit` is parallelized over the variables, + training one decision tree per variable. `None` means 1 unless in a + `joblib.parallel_backend` context. `-1` means using all processors. Attributes ---------- From 8460d138b44ea7a0ace3b4b516308f275c33b13a Mon Sep 17 00:00:00 2001 From: Soledad Galli Date: Fri, 18 Sep 2026 14:14:48 +0200 Subject: [PATCH 08/10] Call is_pandas_dataframe in the condition instead of storing it Co-Authored-By: Claude Opus 5 --- feature_engine/discretisation/decision_tree.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/feature_engine/discretisation/decision_tree.py b/feature_engine/discretisation/decision_tree.py index c3b0236f0..379da7dff 100644 --- a/feature_engine/discretisation/decision_tree.py +++ b/feature_engine/discretisation/decision_tree.py @@ -340,7 +340,6 @@ def transform(self, X: IntoDataFrame) -> IntoDataFrame: # check input dataframe and if class was fitted X = self._check_transform_input_and_state(X) - is_pandas = nwd.is_pandas_dataframe(X) nw_X = nw.from_native(X, eager_only=True) # build every replacement column before touching X, so pandas gets a @@ -378,7 +377,7 @@ def transform(self, X: IntoDataFrame) -> IntoDataFrame: values = nw_X.get_column(feature).to_numpy() new_columns[feature] = self._bin_index(values, thresholds) - if is_pandas is True: + if nwd.is_pandas_dataframe(X) is True: X = X.assign(**new_columns) else: new_series = [ From 68ae21ccd19e53b0d5c080d2f394db37c8744c20 Mon Sep 17 00:00:00 2001 From: Soledad Galli Date: Fri, 18 Sep 2026 14:15:44 +0200 Subject: [PATCH 09/10] Shorten comment in DecisionTreeDiscretiser.transform Co-Authored-By: Claude Opus 5 --- feature_engine/discretisation/decision_tree.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/feature_engine/discretisation/decision_tree.py b/feature_engine/discretisation/decision_tree.py index 379da7dff..d7a3a45e8 100644 --- a/feature_engine/discretisation/decision_tree.py +++ b/feature_engine/discretisation/decision_tree.py @@ -342,11 +342,8 @@ def transform(self, X: IntoDataFrame) -> IntoDataFrame: nw_X = nw.from_native(X, eager_only=True) - # build every replacement column before touching X, so pandas gets a - # single non-mutating `.assign()` and polars a single `.with_columns()` - # instead of one column swap per variable (avoids fragmentation and, - # since check_X no longer copies pandas input, avoids mutating the - # dataframe the caller passed in). + # add all new columns in one step, instead of one per variable, and leave + # the user's dataframe unchanged new_columns: Dict[str, np.ndarray] = {} if self.bin_output == "prediction": From f9c58a81175feab6984347cfdd7f6fb58de1e621 Mon Sep 17 00:00:00 2001 From: Soledad Galli Date: Fri, 18 Sep 2026 14:21:06 +0200 Subject: [PATCH 10/10] Reorder DecisionTreeDiscretiser tests to the test file convention Co-Authored-By: Claude Opus 5 --- .../test_decision_tree_discretiser.py | 102 +++++++++--------- 1 file changed, 49 insertions(+), 53 deletions(-) diff --git a/tests/test_discretisation/test_decision_tree_discretiser.py b/tests/test_discretisation/test_decision_tree_discretiser.py index a6fa80feb..04b4bb892 100644 --- a/tests/test_discretisation/test_decision_tree_discretiser.py +++ b/tests/test_discretisation/test_decision_tree_discretiser.py @@ -26,6 +26,41 @@ def _continuous_target(): # init parameters +@pytest.mark.parametrize( + "bin_output_", ["arbitrary", "Prediction", "", False, 1, None, ["prediction"]] +) +def test_error_if_binoutput_not_permitted_value(bin_output_): + msg = ( + "bin_output takes values 'prediction', 'bin_number' or 'boundaries'. " + f"Got {bin_output_} instead." + ) + with pytest.raises(ValueError, match=re.escape(msg)): + DecisionTreeDiscretiser(bin_output=bin_output_) + + +@pytest.mark.parametrize("precision_", ["arbitrary", -1, 0.3]) +def test_error_if_precision_not_permitted_value(precision_): + msg = "precision must be None or a positive integer. " f"Got {precision_} instead." + with pytest.raises(ValueError, match=re.escape(msg)): + DecisionTreeDiscretiser(precision=precision_) + + +def test_precision_errors_if_none_when_bin_output_is_boundaries(): + msg = ( + "When `bin_output == 'boundaries', `precision` cannot be None. " + "Change precision's value to a positive integer." + ) + with pytest.raises(ValueError, match=re.escape(msg)): + DecisionTreeDiscretiser(precision=None, bin_output="boundaries") + + +@pytest.mark.parametrize("regression_", ["arbitrary", -1, 0.3, 1, None]) +def test_error_if_regression_is_not_bool(regression_): + msg = "regression can only take True or False. " f"Got {regression_} instead." + with pytest.raises(ValueError, match=re.escape(msg)): + DecisionTreeDiscretiser(regression=regression_) + + @pytest.mark.parametrize( "params", [ @@ -67,45 +102,7 @@ def test_init_param_assignment(params): assert getattr(transformer, param) == value -@pytest.mark.parametrize( - "bin_output_", ["arbitrary", "Prediction", "", False, 1, None, ["prediction"]] -) -def test_error_if_binoutput_not_permitted_value(bin_output_): - msg = ( - "bin_output takes values 'prediction', 'bin_number' or 'boundaries'. " - f"Got {bin_output_} instead." - ) - with pytest.raises(ValueError, match=re.escape(msg)): - DecisionTreeDiscretiser(bin_output=bin_output_) - - -@pytest.mark.parametrize("precision_", ["arbitrary", -1, 0.3]) -def test_error_if_precision_not_permitted_value(precision_): - msg = "precision must be None or a positive integer. " f"Got {precision_} instead." - with pytest.raises(ValueError, match=re.escape(msg)): - DecisionTreeDiscretiser(precision=precision_) - - -def test_precision_errors_if_none_when_bin_output_is_boundaries(): - msg = ( - "When `bin_output == 'boundaries', `precision` cannot be None. " - "Change precision's value to a positive integer." - ) - with pytest.raises(ValueError, match=re.escape(msg)): - DecisionTreeDiscretiser(precision=None, bin_output="boundaries") - - dsc = DecisionTreeDiscretiser(precision=None, bin_output="bin_number") - assert dsc.precision is None - - -@pytest.mark.parametrize("regression_", ["arbitrary", -1, 0.3]) -def test_error_if_regression_is_not_bool(regression_): - msg = "regression can only take True or False. " f"Got {regression_} instead." - with pytest.raises(ValueError, match=re.escape(msg)): - DecisionTreeDiscretiser(regression=regression_) - - -# fit +# fit and transform def test_error_if_y_not_passed(make_df, data_normal_dist): encoder = DecisionTreeDiscretiser() msg = "DecisionTreeDiscretiser.fit() missing 1 required positional argument: 'y'" @@ -126,6 +123,19 @@ def test_error_when_regression_is_true_and_target_is_binary(make_df): transformer.fit(X, y) +def test_error_when_regression_is_false_and_target_is_continuous(make_df): + X = make_df(DATA_TWO_VARS) + np.random.seed(42) + y = make_series(make_df, np.random.normal(0, 3, 20).tolist()) + transformer = DecisionTreeDiscretiser(regression=False) + msg = ( + "Unknown label type: continuous. Maybe you are trying to fit a classifier, " + "which expects discrete classes on a regression target with continuous values." + ) + with pytest.raises(ValueError, match=re.escape(msg)): + transformer.fit(X, y) + + def test_classification_predictions(make_df, data_normal_dist): X = make_df(data_normal_dist) y = make_series(make_df, _binary_target()) @@ -360,7 +370,6 @@ def test_regression_rounds_predictions(make_df, data_normal_dist, params): assert sorted(set(frame_to_dict(Xt)["var"])) == sorted(params[1]) -# transform def test_non_fitted_error(make_df, data_normal_dist): transformer = DecisionTreeDiscretiser() msg = ( @@ -371,19 +380,6 @@ def test_non_fitted_error(make_df, data_normal_dist): transformer.transform(make_df(data_normal_dist)) -def test_error_when_regression_is_false_and_target_is_continuous(make_df): - X = make_df(DATA_TWO_VARS) - np.random.seed(42) - y = make_series(make_df, np.random.normal(0, 3, 20).tolist()) - transformer = DecisionTreeDiscretiser(regression=False) - msg = ( - "Unknown label type: continuous. Maybe you are trying to fit a classifier, " - "which expects discrete classes on a regression target with continuous values." - ) - with pytest.raises(ValueError, match=re.escape(msg)): - transformer.fit(X, y) - - def test_n_jobs_parallel_matches_sequential(make_df): # parallel tree training must give the same trees and predictions as sequential X = make_df(DATA_TWO_VARS)