From 0295a75f3f6ef857c723d681b363d5631744eccb Mon Sep 17 00:00:00 2001 From: Soledad Galli Date: Tue, 2 Apr 2024 22:25:03 +0200 Subject: [PATCH 1/8] expand decision tree discretizer functionality --- .../discretisation/decision_tree.py | 96 ++++++++++++++++--- 1 file changed, 83 insertions(+), 13 deletions(-) diff --git a/feature_engine/discretisation/decision_tree.py b/feature_engine/discretisation/decision_tree.py index decef6a39..b5e762949 100644 --- a/feature_engine/discretisation/decision_tree.py +++ b/feature_engine/discretisation/decision_tree.py @@ -3,6 +3,7 @@ from typing import Dict, List, Optional, Union +import numpy as np import pandas as pd from sklearn.model_selection import GridSearchCV from sklearn.tree import DecisionTreeClassifier, DecisionTreeRegressor @@ -13,6 +14,7 @@ _check_variables_input_value, ) from feature_engine._docstrings.fit_attributes import ( + _binner_dict_docstring, _feature_names_in_docstring, _n_features_in_docstring, _variables_attribute_docstring, @@ -28,6 +30,7 @@ @Substitution( variables=_variables_numerical_docstring, variables_=_variables_attribute_docstring, + binner_dict_=_binner_dict_docstring, feature_names_in_=_feature_names_in_docstring, n_features_in_=_n_features_in_docstring, fit_transform=_fit_transform_docstring, @@ -35,14 +38,14 @@ class DecisionTreeDiscretiser(BaseNumericalTransformer): """ The DecisionTreeDiscretiser() replaces numerical variables by discrete, i.e., - finite variables, which values are the predictions of a decision tree. + finite variables, whose values are the predictions of a decision tree. The method is inspired by the following article from the winners of the KDD 2009 competition: http://www.mtome.com/Publications/CiML/CiML-v3-book.pdf The DecisionTreeDiscretiser() trains a decision tree per variable. Then, it - transforms the variables, with predictions of the decision tree. + replaces the variable values with the predictions of the decision tree. The DecisionTreeDiscretiser() works only with numerical variables. A list of variables to transform can be indicated. Alternatively, the discretiser will @@ -54,6 +57,15 @@ class DecisionTreeDiscretiser(BaseNumericalTransformer): ---------- {variables} + bin_output: str, default = "prediction" + Whether to return the prediction of the tree, the bin number of the interval + boundaries. Takes values "prediction", "bin_number" and "boundaries", + respectively. + + precision: int, default=None + The precision at which to store and display the bins labels. In other words, + the number of decimals after the comma. + cv: int, cross-validation generator or an iterable, default=3 Determines the cross-validation splitting strategy. Possible inputs for cv are: @@ -97,7 +109,8 @@ class DecisionTreeDiscretiser(BaseNumericalTransformer): Attributes ---------- binner_dict_: - Dictionary containing the fitted tree per variable. + Dictionary with the interval limits per variable or the fitted tree per + variable, depending on how `bin_output` was set up. scores_dict_: Dictionary with the score of the best decision tree per variable. @@ -159,6 +172,8 @@ class DecisionTreeDiscretiser(BaseNumericalTransformer): def __init__( self, variables: Union[None, int, str, List[Union[str, int]]] = None, + bin_output: str = "prediction", + precision: int = None, cv=3, scoring: str = "neg_mean_squared_error", param_grid: Optional[Dict[str, Union[str, int, float, List[int]]]] = None, @@ -166,9 +181,24 @@ def __init__( random_state: Optional[int] = None, ) -> None: + if bin_output not in ["prediction", "bin_number", "boundaries"]: + raise ValueError( + "bin_output takes values 'prediction', 'bin_number' or 'boundaries'. " + f"Got {bin_output} instead." + ) + + if precision is not None and (not isinstance(precision, int) or precision < 1): + raise ValueError( + "precision must be None or a positive integer. " + f"Got {precision} instead." + ) + if not isinstance(regression, bool): - raise ValueError("regression can only take True or False") + raise ValueError("regression can only take True or False. " + f"Got {regression} instead.") + self.bin_output = bin_output + self.precision = precision self.cv = cv self.scoring = scoring self.regression = regression @@ -210,8 +240,8 @@ def fit(self, X: pd.DataFrame, y: pd.Series): # type: ignore else: param_grid = {"max_depth": [1, 2, 3, 4]} - self.binner_dict_ = {} - self.scores_dict_ = {} + binner_dict_ = {} + scores_dict_ = {} for var in self.variables_: @@ -227,9 +257,21 @@ def fit(self, X: pd.DataFrame, y: pd.Series): # type: ignore # fit the model to the variable tree_model.fit(X[var].to_frame(), y) - self.binner_dict_[var] = tree_model - self.scores_dict_[var] = tree_model.score(X[var].to_frame(), y) - + binner_dict_[var] = tree_model + scores_dict_[var] = tree_model.score(X[var].to_frame(), y) + + if self.bin_output != "prediction": + for var in self.variables_: + clf = binner_dict_[var].best_estimator_ + threshold = clf.tree_.threshold + feature = clf.tree_.feature + feature_threshold = threshold[feature == 0] + thresholds = sorted(feature_threshold) + thresholds = [-np.inf] + thresholds + [np.inf] + binner_dict_[var] = thresholds + + self.binner_dict_ = binner_dict_ + self.scores_dict = scores_dict_ return self def transform(self, X: pd.DataFrame) -> pd.DataFrame: @@ -252,11 +294,39 @@ def transform(self, X: pd.DataFrame) -> pd.DataFrame: X = self._check_transform_input_and_state(X) for feature in self.variables_: - if self.regression: - X[feature] = self.binner_dict_[feature].predict(X[feature].to_frame()) + if self.bin_output == "prediction": + 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) + 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) + + elif self.bin_output == "boundaries": + 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) + else: - tmp = self.binner_dict_[feature].predict_proba(X[feature].to_frame()) - X[feature] = tmp[:, 1] + for feature in self.variables_: + X[feature] = pd.cut( + X[feature], + self.binner_dict_[feature], + labels=False, + include_lowest=True, + ) return X From 02fcdbdd69a68fc0c9e1dc085407e1963e93131e Mon Sep 17 00:00:00 2001 From: Soledad Galli Date: Wed, 10 Apr 2024 10:36:42 +0200 Subject: [PATCH 2/8] update docstring init --- .../discretisation/decision_tree.py | 23 ++++++++++--------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/feature_engine/discretisation/decision_tree.py b/feature_engine/discretisation/decision_tree.py index b5e762949..d87da8d69 100644 --- a/feature_engine/discretisation/decision_tree.py +++ b/feature_engine/discretisation/decision_tree.py @@ -14,7 +14,6 @@ _check_variables_input_value, ) from feature_engine._docstrings.fit_attributes import ( - _binner_dict_docstring, _feature_names_in_docstring, _n_features_in_docstring, _variables_attribute_docstring, @@ -30,7 +29,6 @@ @Substitution( variables=_variables_numerical_docstring, variables_=_variables_attribute_docstring, - binner_dict_=_binner_dict_docstring, feature_names_in_=_feature_names_in_docstring, n_features_in_=_n_features_in_docstring, fit_transform=_fit_transform_docstring, @@ -38,17 +36,19 @@ class DecisionTreeDiscretiser(BaseNumericalTransformer): """ The DecisionTreeDiscretiser() replaces numerical variables by discrete, i.e., - finite variables, whose values are the predictions of a decision tree. + finite variables, whose values are the predictions of a decision tree, the bin + number, or the bin limits. The method is inspired by the following article from the winners of the KDD 2009 competition: http://www.mtome.com/Publications/CiML/CiML-v3-book.pdf - The DecisionTreeDiscretiser() trains a decision tree per variable. Then, it - replaces the variable values with the predictions of the decision tree. + The DecisionTreeDiscretiser() trains a decision tree per variable. Then it finds + the limits of boundaries of each bin. Finally, it replaces the variable values with + the predictions of the decision tree, the bin number, or the bin limits. - The DecisionTreeDiscretiser() works only with numerical variables. A list of - variables to transform can be indicated. Alternatively, the discretiser will + The DecisionTreeDiscretiser() works only with numerical variables. You can pass a + list with the variables you wish to transform. Alternatively, the discretiser will automatically select all numerical variables. More details in the :ref:`User Guide `. @@ -58,13 +58,14 @@ class DecisionTreeDiscretiser(BaseNumericalTransformer): {variables} bin_output: str, default = "prediction" - Whether to return the prediction of the tree, the bin number of the interval + Whether to return the predictions of the tree, the bin number, or the interval boundaries. Takes values "prediction", "bin_number" and "boundaries", respectively. precision: int, default=None The precision at which to store and display the bins labels. In other words, - the number of decimals after the comma. + the number of decimals after the comma. Only used when `bin_output` is + "prediction" or "boundaries". cv: int, cross-validation generator or an iterable, default=3 Determines the cross-validation splitting strategy. Possible inputs for cv are: @@ -124,12 +125,12 @@ class DecisionTreeDiscretiser(BaseNumericalTransformer): Methods ------- fit: - Fit a decision tree per variable. + Fit a decision tree per variable and finds the interval limits. {fit_transform} transform: - Replace continuous variable values by the predictions of the decision tree. + Sort continuous variables into intervals or replaces them with the predictions. See Also -------- From 275c8774c8cd3f20918ce8ed9cdfde94d9e786b5 Mon Sep 17 00:00:00 2001 From: Soledad Galli Date: Wed, 10 Apr 2024 10:57:40 +0200 Subject: [PATCH 3/8] fix test in freq_encoder --- tests/test_encoding/test_count_frequency_encoder.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/test_encoding/test_count_frequency_encoder.py b/tests/test_encoding/test_count_frequency_encoder.py index f98f8ccf5..a724344a1 100644 --- a/tests/test_encoding/test_count_frequency_encoder.py +++ b/tests/test_encoding/test_count_frequency_encoder.py @@ -27,12 +27,16 @@ def test_error_if_unseen_gets_not_permitted_value(errors): "params", [("count", "raise", True), ("frequency", "ignore", False)] ) def test_init_param_assignment(params): - CountFrequencyEncoder( + enc = CountFrequencyEncoder( encoding_method=params[0], missing_values=params[1], ignore_format=params[2], unseen=params[1], ) + assert enc.encoding_method == params[0] + assert enc.missing_values == params[1] + assert enc.ignore_format == params[2] + assert enc.unseen == params[1] # fit and transform From 5ae95e393f90852312577b9da136dfb0e8a56f49 Mon Sep 17 00:00:00 2001 From: Soledad Galli Date: Wed, 10 Apr 2024 11:59:35 +0200 Subject: [PATCH 4/8] finish functionality and tests --- .../discretisation/decision_tree.py | 24 +- .../test_decision_tree_discretiser.py | 226 ++++++++++++++++-- 2 files changed, 226 insertions(+), 24 deletions(-) diff --git a/feature_engine/discretisation/decision_tree.py b/feature_engine/discretisation/decision_tree.py index d87da8d69..0191b79d2 100644 --- a/feature_engine/discretisation/decision_tree.py +++ b/feature_engine/discretisation/decision_tree.py @@ -44,7 +44,7 @@ class DecisionTreeDiscretiser(BaseNumericalTransformer): http://www.mtome.com/Publications/CiML/CiML-v3-book.pdf The DecisionTreeDiscretiser() trains a decision tree per variable. Then it finds - the limits of boundaries of each bin. Finally, it replaces the variable values with + the boundaries of each bin. Finally, it replaces the variable values with the predictions of the decision tree, the bin number, or the bin limits. The DecisionTreeDiscretiser() works only with numerical variables. You can pass a @@ -146,8 +146,8 @@ class DecisionTreeDiscretiser(BaseNumericalTransformer): Examples -------- - >>> import pandas as pd >>> import numpy as np + >>> import pandas as pd >>> from feature_engine.discretisation import DecisionTreeDiscretiser >>> np.random.seed(42) >>> X = pd.DataFrame(dict(x= np.random.randint(1,100, 100))) @@ -174,7 +174,7 @@ def __init__( self, variables: Union[None, int, str, List[Union[str, int]]] = None, bin_output: str = "prediction", - precision: int = None, + precision: Union[int, None] = None, cv=3, scoring: str = "neg_mean_squared_error", param_grid: Optional[Dict[str, Union[str, int, float, List[int]]]] = None, @@ -190,13 +190,19 @@ def __init__( if precision is not None and (not isinstance(precision, int) or precision < 1): raise ValueError( - "precision must be None or a positive integer. " + "precision must be None or a positive integer. " f"Got {precision} instead." ) + if bin_output == "boundaries" and precision is None: + raise ValueError( + "When `bin_output == 'boundaries', `precision` cannot be None. " + "Change precision's value to a positive integer." + ) if not isinstance(regression, bool): - raise ValueError("regression can only take True or False. " - f"Got {regression} instead.") + raise ValueError( + "regression can only take True or False. " f"Got {regression} instead." + ) self.bin_output = bin_output self.precision = precision @@ -272,7 +278,7 @@ def fit(self, X: pd.DataFrame, y: pd.Series): # type: ignore binner_dict_[var] = thresholds self.binner_dict_ = binner_dict_ - self.scores_dict = scores_dict_ + self.scores_dict_ = scores_dict_ return self def transform(self, X: pd.DataFrame) -> pd.DataFrame: @@ -303,7 +309,9 @@ def transform(self, X: pd.DataFrame) -> pd.DataFrame: else: X[feature] = np.round(preds, self.precision) else: - tmp = self.binner_dict_[feature].predict_proba(X[feature].to_frame()) + tmp = self.binner_dict_[feature].predict_proba( + X[feature].to_frame() + ) preds = tmp[:, 1] if self.precision is None: X[feature] = preds diff --git a/tests/test_discretisation/test_decision_tree_discretiser.py b/tests/test_discretisation/test_decision_tree_discretiser.py index 286238968..a90d64ab8 100644 --- a/tests/test_discretisation/test_decision_tree_discretiser.py +++ b/tests/test_discretisation/test_decision_tree_discretiser.py @@ -6,7 +6,82 @@ from feature_engine.discretisation import DecisionTreeDiscretiser, EqualWidthDiscretiser -def test_classification(df_normal_dist): +# init parameters +@pytest.mark.parametrize( + "params", + [("prediction", 3, True), ("bin_number", 10, False), ("boundaries", 1, False)], +) +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: + 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: + DecisionTreeDiscretiser(precision=precision_) + assert str(record.value) == msg + + +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) as record: + 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]) +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: + DecisionTreeDiscretiser(regression=regression_) + assert str(record.value) == msg + + +# fit +def test_error_if_y_not_passed(df_normal_dist): + encoder = DecisionTreeDiscretiser() + with pytest.raises(TypeError): + encoder.fit(df_normal_dist) + + +def test_error_when_regression_is_true_and_target_is_binary(df_discretise): + 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 + + +def test_classification_predictions(df_normal_dist): transformer = DecisionTreeDiscretiser( cv=3, @@ -36,6 +111,96 @@ def test_classification(df_normal_dist): ) +@pytest.mark.parametrize( + "params", + [ + (1, [1.0, 0.7, 0.9, 0.0]), + (2, [1.0, 0.71, 0.93, 0.0]), + (3, [1.0, 0.712, 0.933, 0.0]), + ], +) +def test_classification_rounds_predictions(df_normal_dist, params): + + transformer = DecisionTreeDiscretiser( + precision=params[0], + cv=3, + scoring="roc_auc", + variables=None, + param_grid={"max_depth": [1, 2, 3, 4]}, + 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] + + assert list(X["var"].unique()) == bins + + +def test_classification_bin_number(df_normal_dist): + transformer = DecisionTreeDiscretiser( + bin_output="bin_number", + scoring="roc_auc", + param_grid={"max_depth": [1, 2, 3, 4]}, + 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] + limits = [ + -np.inf, + -0.22668930888175964, + -0.09422881528735161, + 0.10165948793292046, + 0.11590901389718056, + np.inf, + ] + + assert transformer.binner_dict_["var"] == limits + assert np.round(transformer.scores_dict_["var"], 3) == np.round( + 0.717391304347826, 3 + ) + assert list(X["var"].unique()) == bins + + +def test_classification_boundaries(df_normal_dist): + transformer = DecisionTreeDiscretiser( + bin_output="boundaries", + precision=3, + scoring="roc_auc", + param_grid={"max_depth": [1, 2, 3, 4]}, + 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]", + ] + limits = [ + -np.inf, + -0.22668930888175964, + -0.09422881528735161, + 0.10165948793292046, + 0.11590901389718056, + np.inf, + ] + + assert transformer.binner_dict_["var"] == limits + assert np.round(transformer.scores_dict_["var"], 3) == np.round( + 0.717391304347826, 3 + ) + assert list(X["var"].unique()) == bins + + def test_regression(df_normal_dist): transformer = DecisionTreeDiscretiser( @@ -83,18 +248,53 @@ def test_regression(df_normal_dist): assert all(x for x in np.round(X["var"].unique(), 2) if x not in X_t) -def test_error_when_regression_is_not_bool(): - with pytest.raises(ValueError): - DecisionTreeDiscretiser(regression="other") +@pytest.mark.parametrize( + "params", + [ + (1, [0.2, 0.0, 0.1, -0.1, -0.3, -0.2]), + ( + 2, + [ + 0.19, + 0.04, + 0.11, + 0.23, + -0.09, + -0.02, + 0.01, + 0.15, + 0.07, + -0.26, + 0.09, + -0.07, + -0.16, + -0.2, + -0.04, + -0.12, + ], + ), + ], +) +def test_regression_rounds_predictions(df_normal_dist, params): + transformer = DecisionTreeDiscretiser( + precision=params[0], + cv=3, + scoring="neg_mean_squared_error", + variables=None, + param_grid={"max_depth": [1, 2, 3, 4]}, + 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] -def test_error_if_y_not_passed(df_normal_dist): - # test case 3: raises error if target is not passed - with pytest.raises(TypeError): - encoder = DecisionTreeDiscretiser() - encoder.fit(df_normal_dist) + assert list(X["var"].unique()) == bins +# transform def test_non_fitted_error(df_vartypes): with pytest.raises(NotFittedError): transformer = EqualWidthDiscretiser() @@ -119,16 +319,10 @@ def df_discretise(): return df -def test_error_when_regression_is_true_and_target_is_binary(df_discretise): - with pytest.raises(ValueError): - transformer = DecisionTreeDiscretiser(regression=True) - transformer.fit(df_discretise[["var_A", "var_B"]], df_discretise["target"]) - - 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 = DecisionTreeDiscretiser(regression=False) transformer.fit(df_discretise[["var_A", "var_B"]], y) From f099991cd51ef4b8427c3dfd54dea3df5944a4dd Mon Sep 17 00:00:00 2001 From: Soledad Galli Date: Wed, 10 Apr 2024 15:49:06 +0200 Subject: [PATCH 5/8] expand user guide --- docs/images/treemonotonicprediction.png | Bin 0 -> 24626 bytes docs/images/treepredictionrounded.png | Bin 0 -> 27081 bytes .../DecisionTreeDiscretiser.rst | 485 +++++++++++++++--- .../discretisation/decision_tree.py | 48 +- 4 files changed, 431 insertions(+), 102 deletions(-) create mode 100644 docs/images/treemonotonicprediction.png create mode 100644 docs/images/treepredictionrounded.png diff --git a/docs/images/treemonotonicprediction.png b/docs/images/treemonotonicprediction.png new file mode 100644 index 0000000000000000000000000000000000000000..aac36656a94b4a7cca45e66af3013c7ddd33f1ff GIT binary patch literal 24626 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̩(uMHdYK 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 literal 0 HcmV?d00001 diff --git a/docs/user_guide/discretisation/DecisionTreeDiscretiser.rst b/docs/user_guide/discretisation/DecisionTreeDiscretiser.rst index e4e141320..92ffbf63a 100644 --- a/docs/user_guide/discretisation/DecisionTreeDiscretiser.rst +++ b/docs/user_guide/discretisation/DecisionTreeDiscretiser.rst @@ -5,138 +5,465 @@ DecisionTreeDiscretiser ======================= -The :class:`DecisionTreeDiscretiser()` replaces numerical variables by discrete, i.e., -finite variables, which values are the predictions of a decision tree. The method is -based on the winning solution of the KDD 2009 competition: +Discretization consists of transforming continuous variables into discrete features by creating +a set of contiguous intervals, or bins, that span the range of the variable values. + +Discretization is a common data preprocessing step in many data science projects, as it simplifies +continuous attributes and has the potential to improve model performance or speed up model training. + +Decision tree discretization +---------------------------- + +Decision trees make decisions based on discrete partitions over continuous features. During +training, a decision tree evaluates all possible feature values to find the best cut-point, that is, +the feature value at which the split maximizes the information gain, or in other words, reduces the +impurity. It repeats the procedure at each node until it allocates all samples to certain leaf +nodes or end nodes. Hence, classification and regression trees can naturally find the optimal limits +of the intervals to maximize class coherence. + +Discretization with decision trees consists of using a decision tree algorithm to identify the optimal +partitions for each continuous variable. After finding the optimal partitions, we sort the variable's +values into those intervals. + +Discretization with decision trees is a supervised discretization method, in that, the interval +limits are found based on class or target coherence. In simpler words, we need the target variable +to train the decision trees. + +Advantages +~~~~~~~~~~ + +- The output returned by the decision tree is monotonically related to the target. +- The tree end nodes, or bins, show decreased entropy, that is, the observations within each bin are more similar among themselves than to those of other bins. + +Limitations +~~~~~~~~~~~ + +- Could cause over-fitting +- We need to tune some of the decision tree parameters to obtain the optimal number of intervals. + + +Decision tree discretizer +------------------------- + +The :class:`DecisionTreeDiscretiser()` applies discretization based on the interval limits found +by decision trees algorithms. It uses decision trees to find the optimal interval limits. Next, +it sorts the variable into those intervals. + +The transformed variable can either have the limits of the intervals as values, an ordinal number +representing the interval into which the value was sorted, or alternatively, the prediction of the +decision tree. In any case, the number of values of the variable will be finite. + +In theory, decision tree discretization creates discrete variables with a monotonic relationship +with the target, and hence, the transformed features would be more suitable to train linear models, +like linear or logistic regression. + +Original idea +------------- + +The method of decision tree discretization is based on the winning solution of the KDD 2009 competition: `Niculescu-Mizil, et al. "Winning the KDD Cup Orange Challenge with Ensemble Selection". JMLR: Workshop and Conference Proceedings 7: 23-34. KDD 2009 `_. -In the original article, each feature in the challenge dataset was re-coded by training -a decision tree of limited depth (2, 3 or 4) using that feature alone, and letting the -tree predict the target. The probabilistic predictions of this decision tree were used -as an additional feature, that was now linearly (or at least monotonically) correlated -with the target. +In the original article, each feature in the dataset was re-coded by training a decision tree of limited +depth (2, 3 or 4) using that feature alone, and letting the tree predict the target. The probabilistic +predictions of this decision tree were used as an additional feature that was now linearly (or at least +monotonically) related with the target. According to the authors, the addition of these new features had a significant impact on the performance of linear models. -**Example** +Code examples +------------- + +In the following sections, we will do decision tree discretization to showcase the functionality of +the :class:`DecisionTreeDiscretiser()`. We will discretize 2 numerical variables of the Ames house +prices dataset using decision trees. -In the following example, we re-code 2 numerical variables using decision trees. +First, we will transform the variables using the predictions of the decision trees, next, we will +return the interval limits, and finally, we will return the bin order. -First we load the data and separate it into train and test: +Discretization with the predictions of the decision tree +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +First we load the data and separate it into a training set and a test set: .. code:: python - import numpy as np - import pandas as pd - import matplotlib.pyplot as plt - from sklearn.model_selection import train_test_split + from sklearn.datasets import fetch_openml + from sklearn.model_selection import train_test_split + + data = fetch_openml(name='house_prices', as_frame=True) + data = data.frame + + X = data.drop(['SalePrice', 'Id'], axis=1) + y = data['SalePrice'] - from feature_engine.discretisation import DecisionTreeDiscretiser - # Load dataset - data = data = pd.read_csv('houseprice.csv') + X_train, X_test, y_train, y_test = train_test_split( + X, y, test_size=0.2, random_state=42) - # Separate into train and test sets - X_train, X_test, y_train, y_test = train_test_split( - data.drop(['Id', 'SalePrice'], axis=1), - data['SalePrice'], test_size=0.3, random_state=0) + print(X_train.head()) -Now we set up the discretiser. We will optimise the decision tree's depth using 3 fold -cross-validation. +In the following output we see the predictor variables of the house prices dataset: .. code:: python - # set up the discretisation transformer - disc = DecisionTreeDiscretiser(cv=3, + MSSubClass MSZoning LotFrontage LotArea Street Alley LotShape \ + 254 20 RL 70.0 8400 Pave NaN Reg + 1066 60 RL 59.0 7837 Pave NaN IR1 + 638 30 RL 67.0 8777 Pave NaN Reg + 799 50 RL 60.0 7200 Pave NaN Reg + 380 50 RL 50.0 5000 Pave Pave Reg + + LandContour Utilities LotConfig ... ScreenPorch PoolArea PoolQC Fence \ + 254 Lvl AllPub Inside ... 0 0 NaN NaN + 1066 Lvl AllPub Inside ... 0 0 NaN NaN + 638 Lvl AllPub Inside ... 0 0 NaN MnPrv + 799 Lvl AllPub Corner ... 0 0 NaN MnPrv + 380 Lvl AllPub Inside ... 0 0 NaN NaN + + MiscFeature MiscVal MoSold YrSold SaleType SaleCondition + 254 NaN 0 6 2010 WD Normal + 1066 NaN 0 5 2009 WD Normal + 638 NaN 0 5 2008 WD Normal + 799 NaN 0 6 2007 WD Normal + 380 NaN 0 5 2010 WD Normal + + [5 rows x 79 columns] + + +We set up the decision tree discretiser to find the optimal intervals using decision trees. + +The :class:`DecisionTreeDiscretiser()` will optimize the depth of the decision tree classifier +or regressor by default and using cross-validation. That's why we need to select the appropriate +metric for the optimization. In this example, we are using decision tree regression, so we select +the mean squared error metric. + +We specify in the `bin_output` that we want to replace the continuous attribute values with the +predictions of the decision tree. + +.. code:: python + + from feature_engine.discretisation import DecisionTreeDiscretiser + + disc = DecisionTreeDiscretiser(bin_output="prediction", + cv=3, scoring='neg_mean_squared_error', variables=['LotArea', 'GrLivArea'], regression=True) - # fit the transformer - disc.fit(X_train, y_train) + disc.fit(X_train, y_train) +The scoring and cv parameter work exactly as those from any scikit-learn estimator. So we can pass +any value that is also valid for those estimators. Check scikit-learn's documentation for more information. -With `fit()` the transformer fits a decision tree per variable. Then, we can go -ahead replace the variable values by the predictions of the trees: +With `fit()` the transformer fits a decision tree for each one of the continuous features. Then, +we can go ahead replace the variable values by the predictions of the trees and display the transformed +variables: .. code:: python - # transform the data - train_t= disc.transform(X_train) - test_t= disc.transform(X_test) + train_t = disc.transform(X_train) + test_t = disc.transform(X_test) + + print(train_t[['LotArea', 'GrLivArea']].head()) + +In this case, the original values were replaced with the predictions of each one of the decision trees: + +.. code:: python + + LotArea GrLivArea + 254 144174.283688 152471.713568 + 1066 144174.283688 191760.966667 + 638 176117.741848 97156.250000 + 799 144174.283688 202178.409091 + 380 144174.283688 202178.409091 + +Decision trees make discrete predictions, that's why we'll see a limited number of values in the +transformed variables: + +.. code:: python + + train_t[['LotArea', 'GrLivArea']].nunique() + +.. code:: python + + LotArea 4 + GrLivArea 16 + dtype: int64 The `binner_dict_` stores the details of each decision tree. .. code:: python - disc.binner_dict_ + disc.binner_dict_ .. code:: python - {'LotArea': GridSearchCV(cv=3, error_score='raise-deprecating', - estimator=DecisionTreeRegressor(criterion='mse', max_depth=None, - max_features=None, - max_leaf_nodes=None, - min_impurity_decrease=0.0, - min_impurity_split=None, - min_samples_leaf=1, - min_samples_split=2, - min_weight_fraction_leaf=0.0, - presort=False, random_state=None, - splitter='best'), - iid='warn', n_jobs=None, param_grid={'max_depth': [1, 2, 3, 4]}, - pre_dispatch='2*n_jobs', refit=True, return_train_score=False, - scoring='neg_mean_squared_error', verbose=0), - 'GrLivArea': GridSearchCV(cv=3, error_score='raise-deprecating', - estimator=DecisionTreeRegressor(criterion='mse', max_depth=None, - max_features=None, - max_leaf_nodes=None, - min_impurity_decrease=0.0, - min_impurity_split=None, - min_samples_leaf=1, - min_samples_split=2, - min_weight_fraction_leaf=0.0, - presort=False, random_state=None, - splitter='best'), - iid='warn', n_jobs=None, param_grid={'max_depth': [1, 2, 3, 4]}, - pre_dispatch='2*n_jobs', refit=True, return_train_score=False, - scoring='neg_mean_squared_error', verbose=0)} + {'LotArea': GridSearchCV(cv=3, estimator=DecisionTreeRegressor(), + param_grid={'max_depth': [1, 2, 3, 4]}, + scoring='neg_mean_squared_error'), + 'GrLivArea': GridSearchCV(cv=3, estimator=DecisionTreeRegressor(), + param_grid={'max_depth': [1, 2, 3, 4]}, + scoring='neg_mean_squared_error')} -With tree discretisation, each bin, that is, each prediction value, does not necessarily -contain the same number of observations. +With decision tree discretisation, each bin, that is, each prediction value in this case, does not +necessarily contain the same number of observations. Let's check that out with a visualization: .. code:: python - # with tree discretisation, each bin does not necessarily contain - # the same number of observations. - train_t.groupby('GrLivArea')['GrLivArea'].count().plot.bar() - plt.ylabel('Number of houses') + import matplotlib.pyplot as plt + train_t.groupby('GrLivArea')['GrLivArea'].count().plot.bar() + plt.ylabel('Number of houses') + plt.show() .. image:: ../../images/treediscretisation.png -**Note** +Finally, we can determine if we have a monotonic relationship with the target after the transformation: + +.. code:: python + + plt.scatter(test_t['GrLivArea'], y_test) + plt.xlabel('GrLivArea') + plt.ylabel('Sale Price') + plt.show() + +.. image:: ../../images/treemonotonicprediction.png + +Rounding the prediction value +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Sometimes, the output of the prediction can have multiple values after the comma, which makes the +visualization and interpretation a bit uncomfortable. Fortunately, we can round those values through +the `precision` parameter: + +.. code:: python + + disc = DecisionTreeDiscretiser( + bin_output="prediction", + precision=1, + cv=3, + scoring='neg_mean_squared_error', + variables=['LotArea', 'GrLivArea'], + regression=True) + + disc.fit(X_train, y_train) + + train_t= disc.transform(X_train) + test_t= disc.transform(X_test) + + train_t.groupby('GrLivArea')['GrLivArea'].count().plot.bar() + plt.ylabel('Number of houses') + plt.show() + +.. image:: ../../images/treepredictionrounded.png + +In this example, we are predicting house prices, which is a continuous target. The procedure for +classification models is identical, we just need to set the parameter `regression` to False. + +Discretization with interval limits +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +In this section, instead of replacing the original variable values with the predictions of the +decision tree, we will return the limits of the intervals. When returning interval boundaries, +we need to set the precision to a positive integer. + +.. code:: python + + disc = DecisionTreeDiscretiser( + bin_output="boundaries", + precision=3, + cv=3, + scoring='neg_mean_squared_error', + variables=['LotArea', 'GrLivArea'], + regression=True) + + # fit the transformer + disc.fit(X_train, y_train) + +In this case, when we explore the `binner_dict_` attribute, we will see the interval limits instead +of the decision trees: + +.. code:: python + + disc.binner_dict_ + +.. code:: python + + {'LotArea': [-inf, 8637.5, 10924.0, 13848.5, inf], + 'GrLivArea': [-inf, + 749.5, + 808.0, + 1049.0, + 1144.5, + 1199.0, + 1413.0, + 1438.5, + 1483.0, + 1651.5, + 1825.0, + 1969.5, + 2386.0, + 2408.0, + 2661.0, + 4576.0, + inf]} + +The :class:`DecisionTreeDiscretiser()` will use these limits with `pandas.cut` to discretize the +continuous variable values during transform: + +.. code:: python + + train_t = disc.transform(X_train) + test_t = disc.transform(X_test) + + print(train_t[['LotArea', 'GrLivArea']].head()) + +In the following output we see the interval limits into which the values of the continuous attributes were sorted: + +.. code:: python -Our implementation of the :class:`DecisionTreeDiscretiser()` will replace the original -values of the variable by the predictions of the trees. This is not strictly identical -to what the winners of the KDD competition did. They added the predictions of the features -as new variables, while keeping the original ones. + LotArea GrLivArea + 254 (-inf, 8637.5] (1199.0, 1413.0] + 1066 (-inf, 8637.5] (1483.0, 1651.5] + 638 (8637.5, 10924.0] (749.5, 808.0] + 799 (-inf, 8637.5] (1651.5, 1825.0] + 380 (-inf, 8637.5] (1651.5, 1825.0] -More details -^^^^^^^^^^^^ +To train machine learning algorithms we would follow that up with any categorical data encoding method. + +Discretization with ordinal numbers +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +In the last part of this guide, we will replace the variable values with the number of bin into +which the value was sorted. Here, 0 is the first bin, 1 the second, and so on. + +.. code:: python + + disc = DecisionTreeDiscretiser( + bin_output="bin_number", + cv=3, + scoring='neg_mean_squared_error', + variables=['LotArea', 'GrLivArea'], + regression=True, + ) + + # fit the transformer + disc.fit(X_train, y_train) + +The `binner_dict_` will also contain the limits of the intervals: + +.. code:: python + + disc.binner_dict_ + +.. code:: python + + {'LotArea': [-inf, 8637.5, 10924.0, 13848.5, inf], + 'GrLivArea': [-inf, + 749.5, + 808.0, + 1049.0, + 1144.5, + 1199.0, + 1413.0, + 1438.5, + 1483.0, + 1651.5, + 1825.0, + 1969.5, + 2386.0, + 2408.0, + 2661.0, + 4576.0, + inf]} + +When we apply transform, :class:`DecisionTreeDiscretiser()` will use these limits with `pandas.cut` to +discretize the continuous variable: + +.. code:: python + + train_t = disc.transform(X_train) + test_t = disc.transform(X_test) + + print(train_t[['LotArea', 'GrLivArea']].head()) + +In the following output we see the interval numbers into which the values of the continuous attributes +were sorted: + +.. code:: python + + LotArea GrLivArea + 254 0 5 + 1066 0 8 + 638 1 1 + 799 0 9 + 380 0 9 + +Additional considerations +------------------------- + +Decision tree discretization uses scikit-learn's DecisionTreeRegressor or DecisionTreeClassifier under +the hood to find the optimal interval limits. These models do not support missing data. Hence, we need +to replace missing values with numbers before proceeding with the disrcretization. + +Tutorials, books and courses +---------------------------- Check also for more details on how to use this transformer: - `Jupyter notebook `_ - `tree_pipe in cell 21 of this Kaggle kernel `_ -For more details about this and other feature engineering methods check out these resources: - -- `Feature engineering for machine learning `_, online course. -- `Python Feature Engineering Cookbook `_, book. \ No newline at end of file +For tutorials about this and other discretization methods and feature engineering techniques check out our online course: + +.. figure:: ../../images/feml.png + :width: 300 + :figclass: align-center + :align: left + :target: https://www.trainindata.com/p/feature-engineering-for-machine-learning + + Feature Engineering for Machine Learning + +| +| +| +| +| +| +| +| +| +| + +Or read our book: + +.. figure:: ../../images/cookbook.png + :width: 200 + :figclass: align-center + :align: left + :target: https://packt.link/0ewSo + + Python Feature Engineering Cookbook + +| +| +| +| +| +| +| +| +| +| +| +| +| + +Both our book and course are suitable for beginners and more advanced data scientists +alike. By purchasing them you are supporting Sole, the main developer of Feature-engine. \ No newline at end of file diff --git a/feature_engine/discretisation/decision_tree.py b/feature_engine/discretisation/decision_tree.py index 0191b79d2..51b766ddf 100644 --- a/feature_engine/discretisation/decision_tree.py +++ b/feature_engine/discretisation/decision_tree.py @@ -65,7 +65,8 @@ class DecisionTreeDiscretiser(BaseNumericalTransformer): precision: int, default=None The precision at which to store and display the bins labels. In other words, the number of decimals after the comma. Only used when `bin_output` is - "prediction" or "boundaries". + "prediction" or "boundaries". If `bin_output="boundaries"` then precision + cannot be None. cv: int, cross-validation generator or an iterable, default=3 Determines the cross-validation splitting strategy. Possible inputs for cv are: @@ -125,12 +126,12 @@ class DecisionTreeDiscretiser(BaseNumericalTransformer): Methods ------- fit: - Fit a decision tree per variable and finds the interval limits. + Fit a decision tree per variable and find the interval limits. {fit_transform} transform: - Sort continuous variables into intervals or replaces them with the predictions. + Sort continuous variables into intervals or replace them with the predictions. See Also -------- @@ -201,7 +202,7 @@ def __init__( ) if not isinstance(regression, bool): raise ValueError( - "regression can only take True or False. " f"Got {regression} instead." + f"regression can only take True or False. Got {regression} instead." ) self.bin_output = bin_output @@ -300,8 +301,9 @@ def transform(self, X: pd.DataFrame) -> pd.DataFrame: # check input dataframe and if class was fitted X = self._check_transform_input_and_state(X) - for feature in self.variables_: - if self.bin_output == "prediction": + + 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: @@ -318,24 +320,24 @@ def transform(self, X: pd.DataFrame) -> pd.DataFrame: else: X[feature] = np.round(preds, self.precision) - elif self.bin_output == "boundaries": - 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) + elif self.bin_output == "boundaries": + 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) - else: - for feature in self.variables_: - X[feature] = pd.cut( - X[feature], - self.binner_dict_[feature], - labels=False, - include_lowest=True, - ) + else: + for feature in self.variables_: + X[feature] = pd.cut( + X[feature], + self.binner_dict_[feature], + labels=False, + include_lowest=True, + ) return X From 3567bb903d39ccfec87b131372dbf119f3aec428 Mon Sep 17 00:00:00 2001 From: Soledad Galli Date: Wed, 10 Apr 2024 15:51:19 +0200 Subject: [PATCH 6/8] fix style --- feature_engine/discretisation/decision_tree.py | 1 - 1 file changed, 1 deletion(-) diff --git a/feature_engine/discretisation/decision_tree.py b/feature_engine/discretisation/decision_tree.py index 51b766ddf..13822650f 100644 --- a/feature_engine/discretisation/decision_tree.py +++ b/feature_engine/discretisation/decision_tree.py @@ -301,7 +301,6 @@ def transform(self, X: pd.DataFrame) -> pd.DataFrame: # check input dataframe and if class was fitted X = self._check_transform_input_and_state(X) - if self.bin_output == "prediction": for feature in self.variables_: if self.regression: From 94efc015e01132fd01ff1f6aa39218f996e98603 Mon Sep 17 00:00:00 2001 From: Soledad Galli Date: Wed, 10 Apr 2024 15:58:14 +0200 Subject: [PATCH 7/8] add change to changelog --- docs/whats_new/v_170.rst | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/docs/whats_new/v_170.rst b/docs/whats_new/v_170.rst index adf8e5471..cc001fcff 100644 --- a/docs/whats_new/v_170.rst +++ b/docs/whats_new/v_170.rst @@ -1,6 +1,30 @@ Version 1.7.X ============= + +Version 1.7.1 +------------- + +Deployed: XXth XX 2024 + +Contributors +~~~~~~~~~~~~ + +- `Soledad Galli `_ + +TBD + +New functionality +~~~~~~~~~~~~~~~~~ + +TBD + +Enhancements +~~~~~~~~~~~~ + +- The `DecisionTreeDiscretiser()` can now replace the continuous attributes with the decision tree predictions, interval limits, or bin numnber (`Soledad Galli `_) + + Version 1.7.0 ------------- From 97cdd3810fb3d5c99de50cac36410e99e40e994b Mon Sep 17 00:00:00 2001 From: Soledad Galli Date: Wed, 10 Apr 2024 16:02:25 +0200 Subject: [PATCH 8/8] fix typo --- docs/user_guide/discretisation/DecisionTreeDiscretiser.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/user_guide/discretisation/DecisionTreeDiscretiser.rst b/docs/user_guide/discretisation/DecisionTreeDiscretiser.rst index 92ffbf63a..3f913f194 100644 --- a/docs/user_guide/discretisation/DecisionTreeDiscretiser.rst +++ b/docs/user_guide/discretisation/DecisionTreeDiscretiser.rst @@ -211,7 +211,7 @@ The `binner_dict_` stores the details of each decision tree. scoring='neg_mean_squared_error')} -With decision tree discretisation, each bin, that is, each prediction value in this case, does not +With decision tree discretization, each bin, that is, each prediction value in this case, does not necessarily contain the same number of observations. Let's check that out with a visualization: .. code:: python