Source code for RuleTree.tree.RuleTreeCluster

"""
RuleTreeCluster and its variants: RuleTrees for clustering, also used to classify or regress
with clusters found without looking at the target.
"""

import heapq
from typing import Union, Optional

import numpy as np
import pandas as pd
from sklearn.base import ClusterMixin, ClassifierMixin, RegressorMixin
from sklearn.metrics import r2_score

from RuleTree.utils.define import MODEL_TYPE_REG
from RuleTree.base.RuleTreeBaseStump import RuleTreeBaseStump
from RuleTree.exceptions import NoSplitFoundWarning
from RuleTree.stumps.regression.DecisionTreeStumpRegressor import DecisionTreeStumpRegressor
from RuleTree.tree.RuleTree import RuleTree
from RuleTree.tree.RuleTreeClassifier import RuleTreeClassifier
from RuleTree.tree.RuleTreeNode import RuleTreeNode
from RuleTree.tree.RuleTreeRegressor import RuleTreeRegressor
from RuleTree.utils import bic, light_famd
from RuleTree.utils.define import DATA_TYPE_TABULAR


[docs] class RuleTreeCluster(ClusterMixin, RuleTree): """ RuleTree for clustering: each leaf is a cluster described by the rules of its path. To split a node, the instances are projected on their principal components (PCA, MCA or FAMD, for numerical, categorical or mixed data); for each component, a regression stump is fitted to predict it, and the best split is kept according to ``clus_impurity``. A node is split only if the split decreases its BIC (see ``bic_eps``). The tree grows from the largest node. Only tabular data are supported. After fit, the nodes predict integer labels assigned in breadth-first order ('R' -> 0, 'Rl' -> 1, 'Rr' -> 2, ...); ``predict`` returns the label of the leaf of each instance. Parameters ---------- n_components : int, default=2 Number of principal components predicted by the stumps of each node. clus_impurity : {'r2', 'bic'}, default='r2' Criterion to choose the split among the ones on the different components: the variance of the component explained by the split ('r2') or the Bayesian Information Criterion of the two clusters ('bic'). bic_eps : float, default=0.0 A split is kept only if it decreases the BIC of the node by more than bic_eps times its value. max_leaf_nodes : int, default=inf Maximum number of leaves. min_samples_split : int, default=2 Minimum number of instances to split a node. max_depth : int, default=inf Maximum depth of the tree (the root has depth 0). prune_useless_leaves : bool, default=False Whether to merge, after fit, the sibling leaves with the same prediction. base_stumps : RuleTreeBaseStump or list, default=None Regression stumps used to split the nodes, fitted to predict a principal component. If None, DecisionTreeStumpRegressor(). random_state : int, RandomState instance or None, default=None Seed of the principal component analysis and of the random choice of the stumps, also given to the stumps without a random_state. Attributes ---------- label_encoder : dict Node id -> integer label. root : RuleTreeNode Root of the fitted tree. candidate_splits_ : list of tuple ``(probability, stump, data_type)``: the stumps usable on the training data, with the data type they are used on. cache_ : dict Values cached by the stumps during fit (see :meth:`get_cache`). See Also -------- RuleTreeClusterClassifier, RuleTreeClusterRegressor : the clustering tree with leaves that predict a target. Examples -------- >>> from sklearn.datasets import make_blobs >>> from sklearn.metrics import adjusted_rand_score >>> from RuleTree import RuleTreeCluster >>> X, y = make_blobs(n_samples=300, centers=3, cluster_std=0.5, random_state=0) >>> labels = RuleTreeCluster(max_depth=3, random_state=0).fit_predict(X) >>> round(adjusted_rand_score(y, labels), 2) 0.95 """ def __init__(self, n_components: int = 2, clus_impurity: str = 'r2', bic_eps: float = .0, max_leaf_nodes=float('inf'), min_samples_split=2, max_depth=float('inf'), prune_useless_leaves=False, base_stumps: Union[RegressorMixin, list] = None, random_state=None): """Initialize the RuleTreeCluster; see the class documentation for the parameters.""" if base_stumps is None: base_stumps = DecisionTreeStumpRegressor() super().__init__(max_leaf_nodes=max_leaf_nodes, min_samples_split=min_samples_split, max_depth=max_depth, prune_useless_leaves=prune_useless_leaves, base_stumps=base_stumps, stump_selection='random', random_state=random_state) self.n_components = n_components self.clus_impurity = clus_impurity self.bic_eps = bic_eps if self.clus_impurity not in ['bic', 'r2']: raise ValueError('Unknown clustering impurity measure %s' % self.clus_impurity)
[docs] def is_split_useless(self, data: dict, idx: np.ndarray, labels: np.ndarray): """ Whether a split is useless: all the instances go to the same child, or the BIC of the two clusters is not lower than the BIC of the node (by bic_eps times its value). Parameters ---------- data : dict Whole data set, as ``{data_type: array}``. idx : numpy.ndarray Indices of the instances in the node. labels : numpy.ndarray Result of the split on idx (1 left, 2 right). Returns ------- useless : bool """ if len(np.unique(labels)) == 1: return True # CHECK BIC DECREASE X = data[DATA_TYPE_TABULAR] bic_parent = bic(X[idx], [0] * len(idx)) bic_children = bic(X[idx], (np.array(labels) - 1).tolist()) return bic_parent < bic_children - self.bic_eps * np.abs(bic_parent)
def _set_candidate_splits(self, data: dict): """ Candidate splits, as in RuleTree; clustering supports only tabular data. Parameters ---------- data : dict Whole data set, as ``{data_type: array}``. Raises ------ ValueError If data other than tabular are provided. """ if set(data) != {DATA_TYPE_TABULAR}: raise ValueError(f"{self.__class__.__name__} supports only tabular data (X), got {', '.join(data)}.") super()._set_candidate_splits(data)
[docs] def fit_predict(self, X, y=None, **kwargs): """ Fit the tree and return the cluster label of each instance. Parameters ---------- X : numpy.ndarray of shape (n_samples, n_features) Tabular data. y : None Not used, present for API consistency. **kwargs : dict Arguments passed to fit. Returns ------- labels : numpy.ndarray of shape (n_samples,) Cluster labels. """ # non-optimized default implementation; override when a better # method is possible for a given clustering algorithm self.fit(X, **kwargs) return self.predict(X)
[docs] def queue_push(self, node: RuleTreeNode, idx: np.ndarray): """ Push a node into the queue, with priority to the nodes with more instances. Parameters ---------- node : RuleTreeNode The node. idx : numpy.ndarray Indices of the instances reaching the node. """ heapq.heappush(self.queue, (-len(idx), next(self.tiebreaker), idx, node))
[docs] def make_split(self, data: dict, y, idx: np.ndarray, sample_weight=None) -> RuleTreeBaseStump: """ Split a node on its principal components: the instances are projected with PCA, MCA or FAMD (numerical, categorical or mixed data), a stump is fitted to predict each component, and the best split according to ``clus_impurity`` is kept. Parameters ---------- data : dict Whole data set, as ``{data_type: array}`` (only tabular data). y : numpy.ndarray or None Not used by the split (the stumps predict the components). idx : numpy.ndarray Indices of the instances in the node. sample_weight : None Not used, present for API consistency. Returns ------- stump : RuleTreeBaseStump The best stump. Raises ------ NoSplitFoundWarning If no component can be split. """ X = data[DATA_TYPE_TABULAR] n_components_split = min(self.n_components, len(idx)) dtypes = pd.DataFrame(X).infer_objects().dtypes numerical = [i for i, dtype in enumerate(dtypes) if pd.api.types.is_numeric_dtype(dtype)] categorical = [i for i, dtype in enumerate(dtypes) if not pd.api.types.is_numeric_dtype(dtype)] if len(categorical) == 0: # all continuous principal_transform = light_famd.PCA(n_components=n_components_split, random_state=self.random_state) elif len(numerical) == 0: # all categorical principal_transform = light_famd.MCA(n_components=n_components_split, random_state=self.random_state) else: # mixed principal_transform = light_famd.FAMD(n_components=n_components_split, random_state=self.random_state) y_pca = principal_transform.fit_transform(X[idx]) y_pca_all = np.zeros((X.shape[0], y_pca.shape[1])) y_pca_all[idx] = y_pca best_clf = None best_score = float('inf') for i in range(n_components_split): clf, data_type = self._get_random_split() try: clf.fit(data, y_pca_all[:, i], idx=idx, context=self, data_type=data_type) except NoSplitFoundWarning: continue labels = clf.apply(data, idx) if self.clus_impurity == 'r2': # fraction of the variance of the i-th component explained by the split component = y_pca[:, i] component_pred = np.zeros_like(component) for label in np.unique(labels): component_pred[labels == label] = component[labels == label].mean() score = -1 * r2_score(component, component_pred) else: score = bic(X[idx], (labels - 1).tolist()) if score < best_score: best_score = score best_clf = clf if best_clf is None: raise NoSplitFoundWarning(f"No split found on any of the {n_components_split} components") return best_clf
[docs] def prepare_node(self, y: np.ndarray, idx: np.ndarray, node_id: str, node: Optional[RuleTreeNode] = None) -> RuleTreeNode: """ Create a node (or update ``node``) whose prediction is its node id, replaced by an integer label in :meth:`_post_fit_fix`. Parameters ---------- y : numpy.ndarray or None Not used. idx : numpy.ndarray Indices of the instances reaching the node. node_id : str Identifier of the node. node : RuleTreeNode, default=None Existing node to update instead of creating a new one. Returns ------- node : RuleTreeNode """ if node is not None: node.prediction = node.node_id node.samples = len(idx) return node return RuleTreeNode( node_id=node_id, prediction=node_id, prediction_probability=-1, loss=np.nan, classes=np.array(['NA']), parent=None, stump=None, node_l=None, node_r=None, samples=len(idx), log_odds=None )
def _update_node_statistics(self, node, data, y, idx): """ Only the number of instances: the predictions of the nodes are the cluster labels. Parameters ---------- node : RuleTreeNode The node. data : dict Not used. y : numpy.ndarray or None Not used. idx : numpy.ndarray Indices of the instances reaching the node. """ node.samples = len(idx) def _post_fit_fix(self): """ Replace the node ids used as cluster labels during fit with integer labels, assigned in breadth-first order ('R' -> 0, 'Rl' -> 1, 'Rr' -> 2, ...). Labels are recomputed from the node ids, so calling this method more than once is safe. """ node_ids = list(self.root.get_predicates()) + list(self.root.get_leaf_nodes()) self.label_encoder = {node_id: i for i, node_id in enumerate(sorted(node_ids, key=lambda x: (len(x), x)))} self._labels_to_int(self.root) def _labels_to_int(self, node: RuleTreeNode): """ Replace recursively the prediction of each node with its integer label. Parameters ---------- node : RuleTreeNode Root of the subtree. """ node.prediction = self.label_encoder[node.node_id] if node.is_leaf(): return self._labels_to_int(node.node_l) self._labels_to_int(node.node_r) def _get_stumps_task(self): """ Task of the stumps of the tree: they predict the principal components. Returns ------- task : str MODEL_TYPE_REG. """ return MODEL_TYPE_REG def _get_prediction_probas(self, current_node = None, probas=None): """ Not implemented for clustering. Raises ------ NotImplementedError """ raise NotImplementedError()
[docs] class RuleTreeClusterClassifier(ClassifierMixin, RuleTreeCluster): """ RuleTreeCluster whose leaves predict a class: the tree is built as in RuleTreeCluster, without looking at the target, and each node predicts the majority class of its instances, as in RuleTreeClassifier. Parameters ---------- n_components : int, default=2 Number of principal components predicted by the stumps of each node. clus_impurity : {'r2', 'bic'}, default='r2' Criterion to choose the split among the ones on the different components: the variance of the component explained by the split ('r2') or the Bayesian Information Criterion of the two clusters ('bic'). bic_eps : float, default=0.0 A split is kept only if it decreases the BIC of the node by more than bic_eps times its value. max_leaf_nodes : int, default=inf Maximum number of leaves. min_samples_split : int, default=2 Minimum number of instances to split a node. max_depth : int, default=inf Maximum depth of the tree (the root has depth 0). prune_useless_leaves : bool, default=False Whether to merge, after fit, the sibling leaves with the same prediction. base_stumps : RuleTreeBaseStump or list, default=None Regression stumps used to split the nodes, fitted to predict a principal component. If None, DecisionTreeStumpRegressor(). random_state : int, RandomState instance or None, default=None Seed of the principal component analysis and of the random choice of the stumps, also given to the stumps without a random_state. Attributes ---------- classes_ : numpy.ndarray Classes of the target. n_classes_ : int Number of classes. See Also -------- RuleTreeCluster : the clustering tree. RuleTreeClassifier : a tree whose splits are chosen with the target. Examples -------- >>> from sklearn.datasets import make_blobs >>> from RuleTree.tree.RuleTreeCluster import RuleTreeClusterClassifier >>> X, y = make_blobs(n_samples=300, centers=3, cluster_std=0.5, random_state=0) >>> model = RuleTreeClusterClassifier(max_depth=2, random_state=0).fit(X, y) >>> round(model.score(X, y), 2) 0.99 """
[docs] def prepare_node(self, y: np.ndarray, idx: np.ndarray, node_id: str, node: Optional[RuleTreeNode] = None) -> RuleTreeNode: """ Create a node (or update ``node``) with the statistics of classification, as in RuleTreeClassifier. Parameters ---------- y : numpy.ndarray Target of the whole data set. idx : numpy.ndarray Indices of the instances reaching the node. node_id : str Identifier of the node. node : RuleTreeNode, default=None Existing node to update instead of creating a new one. Returns ------- node : RuleTreeNode """ return RuleTreeClassifier.prepare_node(self, y, idx, node_id, node)
def _post_fit_fix(self): """Nothing to do: the nodes already predict class labels.""" def _update_node_statistics(self, node, data, y, idx): """ Statistics of classification, as in RuleTreeClassifier. Parameters ---------- node : RuleTreeNode The node. data : dict Whole data set, as ``{data_type: array}``. y : numpy.ndarray Target of the whole data set. idx : numpy.ndarray Indices of the instances reaching the node. """ RuleTree._update_node_statistics(self, node, data, y, idx)
[docs] def predict_proba(self, X: np.ndarray = None, X_ts=None, X_img=None, X_txt=None): """ Class probabilities of each instance: the ones of the leaf it reaches. Parameters ---------- X : numpy.ndarray of shape (n_samples, n_features), default=None Tabular data. X_ts, X_img, X_txt : default=None Not supported by clustering, present for API consistency. Returns ------- proba : numpy.ndarray of shape (n_samples, n_classes) """ return RuleTreeClassifier.predict_proba(self, X=X, X_ts=X_ts, X_img=X_img, X_txt=X_txt)
[docs] class RuleTreeClusterRegressor(RegressorMixin, RuleTreeCluster): """ RuleTreeCluster whose leaves predict a value: the tree is built as in RuleTreeCluster, without looking at the target, and each node predicts the mean of the target of its instances, as in RuleTreeRegressor. Parameters ---------- n_components : int, default=2 Number of principal components predicted by the stumps of each node. clus_impurity : {'r2', 'bic'}, default='r2' Criterion to choose the split among the ones on the different components: the variance of the component explained by the split ('r2') or the Bayesian Information Criterion of the two clusters ('bic'). bic_eps : float, default=0.0 A split is kept only if it decreases the BIC of the node by more than bic_eps times its value. max_leaf_nodes : int, default=inf Maximum number of leaves. min_samples_split : int, default=2 Minimum number of instances to split a node. max_depth : int, default=inf Maximum depth of the tree (the root has depth 0). prune_useless_leaves : bool, default=False Whether to merge, after fit, the sibling leaves with the same prediction. base_stumps : RuleTreeBaseStump or list, default=None Regression stumps used to split the nodes, fitted to predict a principal component. If None, DecisionTreeStumpRegressor(). random_state : int, RandomState instance or None, default=None Seed of the principal component analysis and of the random choice of the stumps, also given to the stumps without a random_state. Attributes ---------- root : RuleTreeNode Root of the fitted tree. See Also -------- RuleTreeCluster : the clustering tree. RuleTreeRegressor : a tree whose splits are chosen with the target. Examples -------- >>> from sklearn.datasets import load_diabetes >>> from RuleTree.tree.RuleTreeCluster import RuleTreeClusterRegressor >>> X, y = load_diabetes(return_X_y=True) >>> model = RuleTreeClusterRegressor(max_depth=2, random_state=0).fit(X, y) >>> model.predict(X[:2]).round(1) array([177.6, 111.8]) """
[docs] def prepare_node(self, y: np.ndarray, idx: np.ndarray, node_id: str, node: Optional[RuleTreeNode] = None) -> RuleTreeNode: """ Create a node (or update ``node``) with the statistics of regression, as in RuleTreeRegressor. Parameters ---------- y : numpy.ndarray Target of the whole data set. idx : numpy.ndarray Indices of the instances reaching the node. node_id : str Identifier of the node. node : RuleTreeNode, default=None Existing node to update instead of creating a new one. Returns ------- node : RuleTreeNode """ return RuleTreeRegressor.prepare_node(self, y, idx, node_id, node)
def _post_fit_fix(self): """Nothing to do: the nodes already predict the mean of the target.""" def _update_node_statistics(self, node, data, y, idx): """ Statistics of regression, as in RuleTreeRegressor. Parameters ---------- node : RuleTreeNode The node. data : dict Whole data set, as ``{data_type: array}``. y : numpy.ndarray Target of the whole data set. idx : numpy.ndarray Indices of the instances reaching the node. """ RuleTreeRegressor._update_node_statistics(self, node, data, y, idx)