diff --git a/smarttree/_check.py b/smarttree/_check.py index c1cd400..cd63a10 100644 --- a/smarttree/_check.py +++ b/smarttree/_check.py @@ -14,6 +14,7 @@ def check__params( cat_features=None, rank_features=None, hierarchy=None, + na_mode=None, num_na_mode=None, cat_na_mode=None, cat_na_filler=None, @@ -73,6 +74,9 @@ def check__params( if hierarchy is not None: _check__hierarchy(hierarchy) + if na_mode is not None: + _check_na_mode(na_mode) + if num_na_mode is not None: _check__num_na_mode(num_na_mode) @@ -272,6 +276,14 @@ def _check__hierarchy(hierarchy): ) +def _check_na_mode(na_mode): + if na_mode not in ("include_all", "include_best"): + raise ValueError( + "`num_na_mode` must be Literal['include_all', 'include_best']." + f" The current value of `na_mode` is {na_mode!r}." + ) + + def _check__num_na_mode(num_na_mode): if num_na_mode not in ("min", "max", "include_all", "include_best"): raise ValueError( diff --git a/smarttree/_classes.py b/smarttree/_classes.py index 73951e4..2240930 100644 --- a/smarttree/_classes.py +++ b/smarttree/_classes.py @@ -20,6 +20,7 @@ from ._types import ( CatNaModeType, ClassificationCriterionType, + CommonNaModeType, NaModeType, NumNaModeType, VerboseType, @@ -43,10 +44,11 @@ def __init__( cat_features: list[str] | str | None = None, rank_features: dict[str, list] | None = None, hierarchy: dict[str, str | list[str]] | None = None, - num_na_mode: NumNaModeType = "min", - cat_na_mode: CatNaModeType = "as_category", + na_mode: CommonNaModeType = "include_best", + num_na_mode: NumNaModeType | None = None, + cat_na_mode: CatNaModeType | None = None, cat_na_filler: str = "missing_value", - feature_na_mode: dict[str, NaModeType | None] | None = None, + feature_na_mode: dict[str, NaModeType] | None = None, verbose: VerboseType = "WARNING", ) -> None: @@ -62,6 +64,7 @@ def __init__( cat_features=cat_features, rank_features=rank_features, hierarchy=hierarchy, + na_mode=na_mode, num_na_mode=num_na_mode, cat_na_mode=cat_na_mode, cat_na_filler=cat_na_filler, @@ -75,7 +78,7 @@ def __init__( self.__max_leaf_nodes = max_leaf_nodes self.__min_impurity_decrease = min_impurity_decrease self.__max_childs = max_childs - self.__hierarchy = dict() if hierarchy is None else hierarchy + self.__hierarchy = hierarchy or dict() if num_features is None: self.__num_features = [] @@ -91,28 +94,15 @@ def __init__( else: self.__cat_features = cat_features - self.__rank_features = dict() if rank_features is None else rank_features + self.__rank_features = rank_features or dict() self._all_features: list[str] = [] + self.__na_mode = na_mode self.__num_na_mode = num_na_mode self.__cat_na_mode = cat_na_mode self.__cat_na_filler = cat_na_filler - - self.__feature_na_mode: dict[str, NaModeType | None] - if feature_na_mode is None: - self.__feature_na_mode = dict() - else: - self.__feature_na_mode = feature_na_mode - for num_feature in self.num_features: - if num_feature not in self.__feature_na_mode: - self.__feature_na_mode[num_feature] = self.num_na_mode - for cat_feature in self.cat_features: - if cat_feature not in self.__feature_na_mode: - self.__feature_na_mode[cat_feature] = self.cat_na_mode - for rank_feature in self.rank_features: - if rank_feature not in self.__feature_na_mode: - self.__feature_na_mode[rank_feature] = None + self.__feature_na_mode: dict[str, NaModeType] = feature_na_mode or dict() self.logger = logging.getLogger() self.logger.setLevel(verbose) @@ -172,11 +162,15 @@ def hierarchy(self) -> dict[str, str | list[str]]: return self.__hierarchy @property - def num_na_mode(self) -> NumNaModeType: + def na_mode(self) -> CommonNaModeType: + return self.__na_mode + + @property + def num_na_mode(self) -> NumNaModeType | None: return self.__num_na_mode @property - def cat_na_mode(self) -> CatNaModeType: + def cat_na_mode(self) -> CatNaModeType | None: return self.__cat_na_mode @property @@ -184,7 +178,7 @@ def cat_na_filler(self) -> str: return self.__cat_na_filler @property - def feature_na_mode(self) -> dict[str, NaModeType | None]: + def feature_na_mode(self) -> dict[str, NaModeType]: return self.__feature_na_mode @property @@ -244,6 +238,7 @@ def get_params( "cat_features": self.cat_features, "rank_features": self.rank_features, "hierarchy": self.hierarchy, + "na_mode": self.na_mode, "num_na_mode": self.num_na_mode, "cat_na_mode": self.cat_na_mode, "cat_na_filler": self.cat_na_filler, @@ -362,7 +357,17 @@ class SmartDecisionTreeClassifier(BaseSmartDecisionTree): If provided, the algorithm will respect these dependencies when selecting features for splits. - num_na_mode: {"min", "max", "include_all", "include_best"}, default="min" + na_mode: {"include_all", "include_best"}, default="include_best" + The mode of handling missing values in a feature. + + - If "include_all", then while training samples with missing values + are included into all child nodes. While predicting decision is + weighted mean of all decisions in child nodes. + - If "include_best", then while training and prediction samples with + missing values are included into the best child node according to + information gain. + + num_na_mode: {"min", "max", "include_all", "include_best"}, default=None The mode of handling missing values in a numerical feature. - If "min", then missing values are filled with minimum value of @@ -376,7 +381,7 @@ class SmartDecisionTreeClassifier(BaseSmartDecisionTree): missing values are included into the best child node according to information gain. - cat_na_mode: {"as_category", "include_all", "include_best"}, default="as_category" + cat_na_mode: {"as_category", "include_all", "include_best"}, default=None The mode of handling missing values in a categorical feature. - If "as_category", then while training and predicting missing values @@ -416,10 +421,11 @@ def __init__( cat_features: list[str] | str | None = None, rank_features: dict[str, list] | None = None, hierarchy: dict[str, str | list[str]] | None = None, - num_na_mode: NumNaModeType = "min", - cat_na_mode: CatNaModeType = "as_category", + na_mode: CommonNaModeType = "include_best", + num_na_mode: NumNaModeType | None = None, + cat_na_mode: CatNaModeType | None = None, cat_na_filler: str = "missing_value", - feature_na_mode: dict[str, NaModeType | None] | None = None, + feature_na_mode: dict[str, NaModeType] | None = None, verbose: VerboseType = "WARNING", ) -> None: @@ -435,6 +441,7 @@ def __init__( cat_features=cat_features, rank_features=rank_features, hierarchy=hierarchy, + na_mode=na_mode, num_na_mode=num_na_mode, cat_na_mode=cat_na_mode, cat_na_filler=cat_na_filler, @@ -474,9 +481,11 @@ def __repr__(self) -> str: repr_.append(f"rank_features={self.rank_features}") if self.hierarchy: repr_.append(f"hierarchy={self.hierarchy}") - if self.num_na_mode != "min": + if self.na_mode != "include_best": + repr_.append(f"na_mode={self.na_mode!r}") + if self.num_na_mode: repr_.append(f"num_na_mode={self.num_na_mode!r}") - if self.cat_na_mode != "as_category": + if self.cat_na_mode: repr_.append(f"cat_na_mode={self.cat_na_mode!r}") if self.cat_na_filler != "missing_value": repr_.append(f"cat_na_filler={self.cat_na_filler!r}") @@ -534,24 +543,27 @@ def fit(self, X: pd.DataFrame, y: pd.Series) -> Self: ) if unknown_num_features: self.num_features.extend(unknown_num_features) - for num_feature in unknown_num_features: - if num_feature not in self.feature_na_mode: - self.feature_na_mode[num_feature] = self.num_na_mode self.logger.info( f"[{self.__class__.__name__}] [Info] {unknown_num_features} are" " added to `num_features`." ) if unknown_cat_features: self.cat_features.extend(unknown_cat_features) - for cat_feature in unknown_cat_features: - if cat_feature not in self.feature_na_mode: - self.feature_na_mode[cat_feature] = self.cat_na_mode self.logger.info( f"[{self.__class__.__name__}] [Info] {unknown_cat_features} are" " added to `cat_features`." ) self._all_features = X.columns.to_list() + + temp_feature_na_mode = self.feature_na_mode.copy() + self.feature_na_mode.update({f: self.na_mode for f in self._all_features}) + if self.num_na_mode is not None: + self.feature_na_mode.update({f: self.num_na_mode for f in self.num_features}) + if self.cat_na_mode is not None: + self.feature_na_mode.update({f: self.cat_na_mode for f in self.cat_features}) + self.feature_na_mode.update(temp_feature_na_mode) + self.__classes = np.sort(y.unique()) for feature, na_mode in self.feature_na_mode.items(): diff --git a/smarttree/_column_splitter.py b/smarttree/_column_splitter.py index 00abab9..9db0c9f 100644 --- a/smarttree/_column_splitter.py +++ b/smarttree/_column_splitter.py @@ -41,7 +41,7 @@ def __init__( criterion: ClassificationCriterionType, min_samples_split: int, min_samples_leaf: int, - feature_na_mode: dict[str, NaModeType | None], + feature_na_mode: dict[str, NaModeType], ) -> None: self.dataset = dataset @@ -173,7 +173,7 @@ def __init__( criterion: ClassificationCriterionType, min_samples_split: int, min_samples_leaf: int, - feature_na_mode: dict[str, NaModeType | None], + feature_na_mode: dict[str, NaModeType], ) -> None: super().__init__( @@ -272,7 +272,7 @@ def __init__( min_samples_leaf: int, max_leaf_nodes: int | float, max_childs: int | float, - feature_na_mode: dict[str, NaModeType | None], + feature_na_mode: dict[str, NaModeType], ) -> None: super().__init__( @@ -397,7 +397,7 @@ def __init__( min_samples_split: int, min_samples_leaf: int, rank_features: dict[str, list], - feature_na_mode: dict[str, NaModeType | None], + feature_na_mode: dict[str, NaModeType], ) -> None: super().__init__( diff --git a/smarttree/_node_splitter.py b/smarttree/_node_splitter.py index 46f9036..fca0af4 100644 --- a/smarttree/_node_splitter.py +++ b/smarttree/_node_splitter.py @@ -43,7 +43,7 @@ def __init__( num_features: list[str], cat_features: list[str], rank_features: dict[str, list], - feature_na_mode: dict[str, NaModeType | None], + feature_na_mode: dict[str, NaModeType], ) -> None: self.max_depth = max_depth diff --git a/smarttree/_types.py b/smarttree/_types.py index 1fce8f6..927e113 100644 --- a/smarttree/_types.py +++ b/smarttree/_types.py @@ -2,8 +2,12 @@ ClassificationCriterionType = Literal["gini", "entropy", "log_loss"] + +CommonNaModeType = Literal["include_all", "include_best"] NumNaModeType = Literal["min", "max", "include_all", "include_best"] CatNaModeType = Literal["as_category", "include_all", "include_best"] NaModeType = Literal["min", "max", "as_category", "include_all", "include_best"] + VerboseType = Literal["CRITICAL", "ERROR", "WARNING", "INFO", "DEBUG"] | int + SplitType = Literal["numerical", "categorical", "rank"] diff --git a/tests/decision_tree/base/test__get_set_params.py b/tests/decision_tree/base/test__get_set_params.py index 59a4229..84ff8f6 100644 --- a/tests/decision_tree/base/test__get_set_params.py +++ b/tests/decision_tree/base/test__get_set_params.py @@ -17,8 +17,9 @@ "cat_features": [], "rank_features": {}, "hierarchy": {}, - "num_na_mode": "min", - "cat_na_mode": "as_category", + "na_mode": "include_best", + "num_na_mode": None, + "cat_na_mode": None, "cat_na_filler": "missing_value", "feature_na_mode": {}, } @@ -45,6 +46,7 @@ def test__get_params(concrete_smart_tree): ({"cat_features": ["cat_feature"]}, does_not_raise()), ({"rank_features": {"rank_feature": ["1", "2", "3"]}}, does_not_raise()), ({"hierarchy": {"num_feature": "rank_feature"}}, does_not_raise()), + ({"na_mode": "include_all"}, does_not_raise()), ({"num_na_mode": "max"}, does_not_raise()), ({"cat_na_mode": "as_category"}, does_not_raise()), ({"cat_na_filler": "NA"}, does_not_raise()), @@ -58,7 +60,7 @@ def test__get_params(concrete_smart_tree): " Valid parameters are: criterion, max_depth, min_samples_split," " min_samples_leaf, max_leaf_nodes, min_impurity_decrease," " max_childs, num_features, cat_features, rank_features, hierarchy," - " num_na_mode, cat_na_mode, cat_na_filler, feature_na_mode." + " na_mode, num_na_mode, cat_na_mode, cat_na_filler, feature_na_mode." ), ), ), @@ -76,6 +78,7 @@ def test__get_params(concrete_smart_tree): "cat_features", "rank_features", "hierarchy", + "na_mode", "num_na_mode", "cat_na_mode", "cat_na_filler", diff --git a/tests/decision_tree/classifier/test__repr_tree.py b/tests/decision_tree/classifier/test__repr_tree.py index 082365a..26f9733 100644 --- a/tests/decision_tree/classifier/test__repr_tree.py +++ b/tests/decision_tree/classifier/test__repr_tree.py @@ -1,7 +1,12 @@ import pytest from smarttree import SmartDecisionTreeClassifier -from smarttree._types import CatNaModeType, ClassificationCriterionType, NumNaModeType +from smarttree._types import ( + CatNaModeType, + ClassificationCriterionType, + CommonNaModeType, + NumNaModeType, +) CLASS_NAME = SmartDecisionTreeClassifier.__name__ @@ -167,10 +172,23 @@ def test_repr_tree__hierarchy(hierarchy, expected): assert repr(tree_classifier) == expected +@pytest.mark.parametrize( + ("na_mode", "expected"), + [ + ("include_best", f"{CLASS_NAME}()"), + ("include_all", f"{CLASS_NAME}(na_mode='include_all')"), + ], +) +def test_repr_tree__na_mode(na_mode, expected): + na_mode: CommonNaModeType + tree_classifier = SmartDecisionTreeClassifier(na_mode=na_mode) + assert repr(tree_classifier) == expected + + @pytest.mark.parametrize( ("num_na_mode", "expected"), [ - ("min", f"{CLASS_NAME}()"), + (None, f"{CLASS_NAME}()"), ("max", f"{CLASS_NAME}(num_na_mode='max')"), ], ids=["default value", "not default value"], @@ -184,7 +202,7 @@ def test_repr_tree__num_na_mode(num_na_mode, expected): @pytest.mark.parametrize( ("cat_na_mode", "expected"), [ - ("as_category", f"{CLASS_NAME}()"), + (None, f"{CLASS_NAME}()"), ("include_all", f"{CLASS_NAME}(cat_na_mode='include_all')"), ], ids=["default value", "not default value"],