diff --git a/docs/user_guide/discretisation/DecisionTreeDiscretiser.rst b/docs/user_guide/discretisation/DecisionTreeDiscretiser.rst index 80e994b8d..c15d4e418 100644 --- a/docs/user_guide/discretisation/DecisionTreeDiscretiser.rst +++ b/docs/user_guide/discretisation/DecisionTreeDiscretiser.rst @@ -443,6 +443,88 @@ 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` 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 8af4b9b60..d7a3a45e8 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 @@ -115,6 +118,11 @@ 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. `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 ---------- binner_dict_: @@ -163,9 +171,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 +182,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,9 +216,14 @@ 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"]: + 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." @@ -223,9 +255,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 +266,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 +292,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_: + 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 + ) - 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 - ) - - # 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 +322,82 @@ 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) + nw_X = nw.from_native(X, eager_only=True) + + # 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": 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 = self._bin_labels(thresholds, self.precision) + values = nw_X.get_column(feature).to_numpy() + bin_idx = self._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] = self._bin_index(values, thresholds) + + if nwd.is_pandas_dataframe(X) 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" @@ -363,3 +407,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) diff --git a/tests/test_discretisation/test_decision_tree_discretiser.py b/tests/test_discretisation/test_decision_tree_discretiser.py index a90d64ab8..04b4bb892 100644 --- a/tests/test_discretisation/test_decision_tree_discretiser.py +++ b/tests/test_discretisation/test_decision_tree_discretiser.py @@ -1,44 +1,48 @@ +import re + import numpy as np -import pandas as pd import pytest from sklearn.exceptions import NotFittedError -from feature_engine.discretisation import DecisionTreeDiscretiser, EqualWidthDiscretiser +from feature_engine.discretisation import DecisionTreeDiscretiser +from tests.backend_helpers import make_series, frame_to_dict + +_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 _binary_target(): + np.random.seed(0) + return np.random.binomial(1, 0.7, 100).tolist() + + +def _continuous_target(): + np.random.seed(0) + return np.random.normal(0, 0.1, 100).tolist() # init parameters @pytest.mark.parametrize( - "params", - [("prediction", 3, True), ("bin_number", 10, False), ("boundaries", 1, False)], + "bin_output_", ["arbitrary", "Prediction", "", False, 1, None, ["prediction"]] ) -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] - - -@pytest.mark.parametrize("bin_output_", ["arbitrary", False, 1]) 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) as record: + with pytest.raises(ValueError, match=re.escape(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=re.escape(msg)): DecisionTreeDiscretiser(precision=precision_) - assert str(record.value) == msg def test_precision_errors_if_none_when_bin_output_is_boundaries(): @@ -46,42 +50,95 @@ 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=re.escape(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 -@pytest.mark.parametrize("regression_", ["arbitrary", -1, 0.3]) +@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) as record: + with pytest.raises(ValueError, match=re.escape(msg)): DecisionTreeDiscretiser(regression=regression_) - assert str(record.value) == msg -# fit -def test_error_if_y_not_passed(df_normal_dist): +@pytest.mark.parametrize( + "params", + [ + { + "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): + transformer = DecisionTreeDiscretiser(**params) + for param, value in params.items(): + assert getattr(transformer, param) == value + + +# fit and transform +def test_error_if_y_not_passed(make_df, data_normal_dist): encoder = DecisionTreeDiscretiser() - with pytest.raises(TypeError): - encoder.fit(df_normal_dist) + msg = "DecisionTreeDiscretiser.fit() missing 1 required positional argument: 'y'" + with pytest.raises(TypeError, match=re.escape(msg)): + encoder.fit(make_df(data_normal_dist)) -def test_error_when_regression_is_true_and_target_is_binary(df_discretise): +def test_error_when_regression_is_true_and_target_is_binary(make_df): + 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) as record: - transformer.fit(df_discretise[["var_A", "var_B"]], df_discretise["target"]) - assert str(record.value) == msg + with pytest.raises(ValueError, match=re.escape(msg)): + 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(df_normal_dist): +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, @@ -91,26 +148,44 @@ def test_classification_predictions(df_normal_dist): regression=False, 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) + 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 # transform params - assert all(x for x in np.round(X["var"].unique(), 2) if x not in X_t) + assert isinstance(Xt, make_df) + 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 ) +@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 frame_to_dict(Xt) == frame_to_dict(from_series.transform(X)) + + @pytest.mark.parametrize( "params", [ @@ -119,7 +194,9 @@ 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, data_normal_dist, params): + X = make_df(data_normal_dist) + y = make_series(make_df, _binary_target()) transformer = DecisionTreeDiscretiser( precision=params[0], @@ -130,15 +207,15 @@ def test_classification_rounds_predictions(df_normal_dist, params): regression=False, 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 = params[1] + Xt = transformer.fit_transform(X, y) - assert list(X["var"].unique()) == bins + assert isinstance(Xt, make_df) + assert sorted(set(frame_to_dict(Xt)["var"])) == sorted(params[1]) -def test_classification_bin_number(df_normal_dist): +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", @@ -146,10 +223,8 @@ def test_classification_bin_number(df_normal_dist): regression=False, 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] + Xt = transformer.fit_transform(X, y) + bins = [0, 1, 2, 3, 4] limits = [ -np.inf, -0.22668930888175964, @@ -163,10 +238,13 @@ 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 isinstance(Xt, make_df) + assert sorted(set(frame_to_dict(Xt)["var"])) == bins -def test_classification_boundaries(df_normal_dist): +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, @@ -175,16 +253,16 @@ def test_classification_boundaries(df_normal_dist): regression=False, 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]", - ] + 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 +276,13 @@ 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 isinstance(Xt, make_df) + assert sorted(set(frame_to_dict(Xt)["var"])) == bins -def test_regression(df_normal_dist): +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, @@ -211,9 +292,7 @@ def test_regression(df_normal_dist): regression=True, 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) + Xt = transformer.fit_transform(X, y) X_t = [ 0.19, 0.04, @@ -233,11 +312,6 @@ def test_regression(df_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 @@ -245,7 +319,9 @@ 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) + assert isinstance(Xt, make_df) + 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) @pytest.mark.parametrize( @@ -275,7 +351,9 @@ def test_regression(df_normal_dist): ), ], ) -def test_regression_rounds_predictions(df_normal_dist, params): +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], @@ -286,43 +364,39 @@ def test_regression_rounds_predictions(df_normal_dist, params): regression=True, 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) - bins = params[1] + Xt = transformer.fit_transform(X, y) - assert list(X["var"].unique()) == bins + assert isinstance(Xt, make_df) + assert sorted(set(frame_to_dict(Xt)["var"])) == sorted(params[1]) -# transform -def test_non_fitted_error(df_vartypes): - with pytest.raises(NotFittedError): - transformer = EqualWidthDiscretiser() - transformer.transform(df_vartypes) +def test_non_fitted_error(make_df, data_normal_dist): + transformer = DecisionTreeDiscretiser() + 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)) -@pytest.fixture(scope="module") -def df_discretise(): - 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], - } - - df = pd.DataFrame(data) +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) + np.random.seed(0) + y = make_series(make_df, np.random.normal(0, 1, 20).tolist()) - return df + 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) + Xt_seq = tr_seq.transform(X) + Xt_par = tr_par.transform(X) -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) + assert isinstance(Xt_par, make_df) + assert frame_to_dict(Xt_par) == frame_to_dict(Xt_seq)