Expand source code
from copy import deepcopy
from typing import List
import itertools

import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
from sklearn import tree
from sklearn.base import BaseEstimator, ClassifierMixin, RegressorMixin
from sklearn.model_selection import cross_val_score
from sklearn.tree import plot_tree
from sklearn.utils import check_array
from joblib import Parallel, delayed
from sklearn.utils.class_weight import compute_sample_weight
from sklearn.utils.validation import _check_sample_weight, check_is_fitted

from scipy.special import softmax

from imodels.tree.viz_utils import extract_sklearn_tree_from_figs
from imodels.util.arguments import check_fit_arguments, check_predict_X
from imodels.util.data_util import encode_categories


class Node:
    def __init__(
        self,
        feature: int = None,
        threshold: int = None,
        value=None,
        value_sklearn=None,
        idxs=None,
        is_root: bool = False,
        left=None,
        impurity: float = None,
        impurity_reduction: float = None,
        tree_num: int = None,
        node_id: int = None,
        right=None,
        depth=None,
    ):
        """Node class for splitting"""

        # split or linear
        self.is_root = is_root
        self.idxs = idxs
        self.tree_num = tree_num
        self.node_id = None
        self.feature = feature
        self.impurity = impurity
        self.impurity_reduction = impurity_reduction
        self.value_sklearn = value_sklearn

        # different meanings
        self.value = value # for split this is mean, for linear thifs is weight
        if isinstance(self.value, np.ndarray):
            self.value = self.value.reshape(-1, )

        # split-specific
        self.threshold = threshold
        self.left = left
        self.right = right
        self.left_temp = None
        self.right_temp = None
        #root node has depth 0
        self.depth = depth

    def setattrs(self, **kwargs):
        for k, v in kwargs.items():
            setattr(self, k, v)

    def __str__(self):
        if self.is_root:
            return f"X_{self.feature} <= {self.threshold:0.3f} (Tree #{self.tree_num} root)"
        elif self.left is None and self.right is None:
            return f"Val: {' '.join([str(np.round(i, 3)) for i in self.value])} (leaf)"
        else:
            return f"X_{self.feature} <= {self.threshold:0.3f} (split)"

    def print_root(self, y, is_classmixin, n_outputs):
        if is_classmixin:
            unique, counts = np.unique(y, return_counts=True)
            class_counts = np.zeros(n_outputs, dtype=int)
            class_counts[unique] = counts
        else:
            class_counts = np.zeros(n_outputs, dtype=int) 

        class_counts_str = ", ".join(map(str, class_counts))
        proportions_str = ", ".join(f"{p:.2f}" for p in np.round(100 * class_counts / y.shape[0], 2))

        one_proportion = f" [{class_counts_str}]/{y.shape[0]} ({proportions_str}%)"
        if self.is_root:
            return f"X_{self.feature} <= {self.threshold:0.3f}" + one_proportion
        elif self.left is None and self.right is None:
            return "ΔRisk = [" + ", ".join(f"{v:.2f}" for v in self.value) + "]" + one_proportion
        else:
            return f"X_{self.feature} <= {self.threshold:0.3f}" + one_proportion


    def __repr__(self):
        return self.__str__()


class FIGS(BaseEstimator):
    """FIGS (sum of trees) classifier.
    Fast Interpretable Greedy-Tree Sums (FIGS) is an algorithm for fitting concise rule-based models.
    Specifically, FIGS generalizes CART to simultaneously grow a flexible number of trees in a summation.
    The total number of splits across all the trees can be restricted by a pre-specified threshold, keeping the model interpretable.
    Experiments across real-world datasets show that FIGS achieves state-of-the-art prediction performance when restricted to just a few splits (e.g. less than 20).
    https://arxiv.org/abs/2201.11931
    """

    def __init__(
        self,
        max_rules: int = 12,
        max_trees: int = None,
        min_impurity_decrease: float = 0.0,
        random_state=None,
        max_features: str = None,
        max_depth: int = None,
        class_weight=None,
        verbose: int = 0,
        n_jobs: int = None,
    ):
        """
        Params
        ------
        max_rules: int
            Max total number of rules across all trees
        max_trees: int
            Max total number of trees
        min_impurity_decrease: float
            A node will be split if this split induces a decrease of the impurity greater than or equal to this value.
        max_features
            The number of features to consider when looking for the best split (see https://scikit-learn.org/stable/modules/generated/sklearn.ensemble.RandomForestClassifier.html)
        n_jobs: int, default=None
            Number of threads used to evaluate candidate splits, which are
            independent of one another. None means 1; -1 uses all processors.
            Only helps once there are several candidates to compare, i.e. on
            larger datasets or deeper models.
        verbose: int, default=0
            Controls progress reporting while fitting. 0 is silent; 1 reports each
            rule as it is added, with the running total; 2 also prints the model
            after every rule. Can be overridden per call via fit(verbose=...).
        class_weight: dict, list of dict or "balanced", default=None
            Classification only. Weights associated with classes, in the form
            {class_label: weight}. "balanced" weights each class by
            n_samples / (n_classes * np.bincount(y)), so that rare classes count
            as much as common ones. Combined multiplicatively with sample_weight
            when both are given.
        """
        super().__init__()
        self.max_rules = max_rules
        self.max_trees = max_trees
        self.min_impurity_decrease = min_impurity_decrease
        self.random_state = random_state
        self.max_features = max_features
        self.max_depth = max_depth
        self.class_weight = class_weight
        self.verbose = verbose
        self.n_jobs = n_jobs
        self.n_outputs = None
        self.need_to_reshape = False


    def get_rules(self, feature_names=None):
        """Return this model's rules as a DataFrame (see imodels.get_rules)."""
        from imodels.util.get_rules import get_rules
        return get_rules(self, feature_names=feature_names)

    def apply(self, X):
        """Return the leaf each sample reaches (see imodels.util.apply.apply_leaves)."""
        from imodels.util.apply import apply_leaves
        return apply_leaves(self, X)
    def _fit_candidate_stumps(self, X, potential_splits, y_residuals_per_tree,
                              sample_weight):
        """Re-fit the stump for every candidate split, in parallel if asked."""
        def fit_stump(potential_split):
            return self._construct_node_with_stump(
                X=X,
                y=y_residuals_per_tree[potential_split.tree_num],
                idxs=potential_split.idxs,
                tree_num=potential_split.tree_num,
                sample_weight=sample_weight,
                max_features=self.max_features,
                depth=potential_split.depth + 1,
            )

        n_jobs = 1 if self.n_jobs is None else self.n_jobs
        if n_jobs == 1 or len(potential_splits) < 2:
            return [fit_stump(split) for split in potential_splits]
        return Parallel(n_jobs=n_jobs, backend="threading")(
            delayed(fit_stump)(split) for split in potential_splits)

    def _apply_class_weight(self, y, sample_weight):
        """Fold class_weight into sample_weight, which the splits already honor."""
        if self.class_weight is None:
            return sample_weight
        if not isinstance(self, ClassifierMixin):
            raise ValueError(
                "class_weight is only meaningful for classification; "
                f"{type(self).__name__} is a regressor. Use sample_weight instead."
            )

        class_based = compute_sample_weight(self.class_weight, np.ravel(y))
        if sample_weight is None:
            return class_based
        return np.asarray(sample_weight, dtype=float) * class_based

    def _construct_node_with_stump(
        self,
        X,
        y,
        idxs,
        tree_num,
        sample_weight=None,
        compare_nodes_with_sample_weight=True,
        max_features=None,
        depth=None,
    ):
        """
        Params
        ------
        compare_nodes_with_sample_weight: Deprecated
            If this is set to true and sample_weight is passed, use sample_weight to compare nodes
            Otherwise, use sample_weight only for picking a split given a particular node
        """

        # array indices
        SPLIT = 0
        LEFT = 1
        RIGHT = 2

        # fit stump
        stump = tree.DecisionTreeRegressor(
            max_depth=1, max_features=max_features)
        sweight = None
        if sample_weight is not None:
            sweight = sample_weight[idxs]
        stump.fit(X[idxs], y[idxs], sample_weight=sweight)

        # these are all arrays, arr[0] is split node
        # note: -2 is dummy
        feature = stump.tree_.feature
        threshold = stump.tree_.threshold

        impurity = stump.tree_.impurity
        n_node_samples = stump.tree_.n_node_samples
        value = stump.tree_.value

        # no split
        if len(feature) == 1:
            # print('no split found!', idxs.sum(), impurity, feature)
            return Node(
                idxs=idxs,
                value=value[SPLIT],
                tree_num=tree_num,
                feature=feature[SPLIT],
                threshold=threshold[SPLIT],
                impurity=impurity[SPLIT],
                impurity_reduction=None,
                depth=depth,
            )

        # manage sample weights
        idxs_split = X[:, feature[SPLIT]] <= threshold[SPLIT]
        idxs_left = idxs_split & idxs
        idxs_right = ~idxs_split & idxs
        if sample_weight is None:
            n_node_samples_left = n_node_samples[LEFT]
            n_node_samples_right = n_node_samples[RIGHT]
        else:
            n_node_samples_left = sample_weight[idxs_left].sum()
            n_node_samples_right = sample_weight[idxs_right].sum()
        n_node_samples_split = n_node_samples_left + n_node_samples_right

        # calculate impurity
        impurity_reduction = (
            impurity[SPLIT]
            - impurity[LEFT] * n_node_samples_left / n_node_samples_split
            - impurity[RIGHT] * n_node_samples_right / n_node_samples_split
        ) * n_node_samples_split

        node_split = Node(
            idxs=idxs,
            value=value[SPLIT],
            tree_num=tree_num,
            feature=feature[SPLIT],
            threshold=threshold[SPLIT],
            impurity=impurity[SPLIT],
            impurity_reduction=impurity_reduction,
            depth=depth,
        )
        # print('\t>>>', node_split, 'impurity', impurity, 'num_pts', idxs.sum(), 'imp_reduc', impurity_reduction)

        # manage children
        node_left = Node(
            idxs=idxs_left,
            value=value[LEFT],
            impurity=impurity[LEFT],
            tree_num=tree_num,
            depth=depth+1,
        )
        node_right = Node(
            idxs=idxs_right,
            value=value[RIGHT],
            impurity=impurity[RIGHT],
            tree_num=tree_num,
            depth=depth+1,
        )
        node_split.setattrs(
            left_temp=node_left,
            right_temp=node_right,
        )
        return node_split

    def _encode_categories(self, X, categorical_features, encoder_name):
        """Apply the encoder stored under encoder_name (fitted during fit) to X."""
        return encode_categories(X, categorical_features, getattr(self, encoder_name))

    def fit(
        self,
        X,
        y=None,
        feature_names=None,
        verbose=None,
        sample_weight=None,
        categorical_features=None,
    ):
        """
        Params
        ------
        _sample_weight: array-like of shape (n_samples,), default=None
            Sample weights. If None, then samples are equally weighted.
            Splits that would create child nodes with net zero or negative weight
            are ignored while searching for a split in each node.
        """
        # fit(verbose=...) still wins, so existing callers are unaffected
        verbose = int(self.verbose if verbose is None else verbose)

        # remembered so that predict/predict_proba don't need them passed again
        self.categorical_features_ = categorical_features
        if categorical_features is not None:
            X, self._encoder = encode_categories(X, categorical_features)

        sample_weight = self._apply_class_weight(y, sample_weight)

        if hasattr(y, 'values'):
            y = y.values
        # y may still be a plain list here, which has no .shape
        y = np.asarray(y)
        if len(y.shape) == 1:
            y = y.reshape(-1, 1)
        
        if isinstance(self, ClassifierMixin):
            assert y.shape[1] == 1, "FIGSClassifier requires a 1-dimensional input"
            if hasattr(y, 'name'):
                class_name = y.name
            elif hasattr(y, 'columns'):
                class_name = y.columns[0]
            else:
                class_name = 'class'

            #self.classes_, y = np.unique(y, return_inverse=True)
            self.classes_ = np.unique(y)
            y, self._class_encoder = encode_categories(
                pd.DataFrame(y, columns=[class_name]), [class_name])
            self.Y = y
            self._class_map = {i:c for i, c in zip(np.arange(0, y.shape[1]), self._class_encoder.inverse_transform(np.eye(y.shape[1])).reshape(-1, ))}
            
        X, y, feature_names = check_fit_arguments(self, X, y, feature_names, True, False)
        self.Y = y
        self.n_outputs = y.shape[1]
        self.n_features = X.shape[1]
        
        if sample_weight is not None:
            sample_weight = _check_sample_weight(sample_weight, X)
            
        self.trees_ = []  # list of the root nodes of added trees
        self.complexity_ = 0  # tracks the number of rules in the model
        y_predictions_per_tree = {}  # predictions for each tree
        y_residuals_per_tree = {}  # based on predictions above

        # set up initial potential_splits
        # everything in potential_splits either is_root (so it can be added directly to self.trees_)
        # or it is a child of a root node that has already been added
        idxs = np.ones(X.shape[0], dtype=bool)
        node_init = self._construct_node_with_stump(
            X=X,
            y=y,
            idxs=idxs,
            tree_num=-1,
            sample_weight=sample_weight,
            max_features=self.max_features,
            depth=0,
        )
        potential_splits = [node_init]
        for node in potential_splits:
            node.setattrs(is_root=True)
        potential_splits = sorted(
            potential_splits, key=lambda x: x.impurity_reduction)

        # start the greedy fitting algorithm
        finished = False
        while len(potential_splits) > 0 and not finished:
            # print('potential_splits', [str(s) for s in potential_splits])
            # get node with max impurity_reduction (since it's sorted)
            split_node = potential_splits.pop()

            # don't split on node.
            # impurity_reduction is None when the stump found no valid split,
            # which happens when y is constant over the node -- there is nothing
            # left to fit, so stop rather than compare None to a float
            if (split_node.impurity_reduction is None
                    or split_node.impurity_reduction < self.min_impurity_decrease):
                # nothing worth splitting on. If that happened before any tree
                # was grown, keep this node as a single leaf: predictions are a
                # sum over trees, so with none at all the model would return 0
                # whatever y is, rather than y's mean.
                if split_node.is_root and not self.trees_:
                    split_node.setattrs(tree_num=0, left=None, right=None)
                    self.trees_.append(split_node)
                finished = True
                break
            elif (
                split_node.is_root
                and self.max_trees is not None
                and len(self.trees_) >= self.max_trees
            ):
                # If the node is the root of a new tree and we have reached self.max_trees,
                # don't split on it, but allow later splits to continue growing existing trees
                continue
            elif (
                self.max_depth is not None
                and split_node.depth > self.max_depth
            ):
                # If the node is deeper than self.max_depth,
                # don't split on it, but allow algorithm to continue
                continue

            # split on node
            self.complexity_ += 1

            # if added a tree root
            if split_node.is_root:
                # start a new tree
                self.trees_.append(split_node)

                # update tree_num
                for node_ in [split_node, split_node.left_temp, split_node.right_temp]:
                    if node_ is not None:
                        node_.tree_num = len(self.trees_) - 1

                # add new root potential node
                node_new_root = Node(
                    is_root=True, idxs=np.ones(X.shape[0], dtype=bool), tree_num=-1, depth=0,
                )
                potential_splits.append(node_new_root)

            # add children to potential splits
            # assign left_temp, right_temp to be proper children
            # (basically adds them to tree in predict method)
            split_node.setattrs(left=split_node.left_temp,
                                right=split_node.right_temp)

            # add children to potential_splits
            potential_splits.append(split_node.left)
            potential_splits.append(split_node.right)

            if verbose >= 1:
                # reported after the bookkeeping above, so the counts are final
                budget = '' if self.max_rules is None else f'/{self.max_rules}'
                condition = (f"X_{split_node.feature} <= {split_node.threshold:0.3f}"
                             if split_node.feature is not None else str(split_node))
                print(f"rule {self.complexity_}{budget} "
                      f"({len(self.trees_)} tree(s)): {condition}")

            # update predictions for altered tree
            for tree_num_ in range(len(self.trees_)):
                y_predictions_per_tree[tree_num_] = self._predict_tree(
                    self.trees_[tree_num_], X
                )
            # dummy 0 preds for possible new trees
            y_predictions_per_tree[-1] = np.zeros((X.shape[0], self.n_outputs))

            # update residuals for each tree
            # -1 is key for potential new tree
            for tree_num_ in list(range(len(self.trees_))) + [-1]:
                y_residuals_per_tree[tree_num_] = deepcopy(y)

                # subtract predictions of all other trees
                # Since the current tree makes a constant prediction over the node being split,
                # one may ignore its contributions to the residuals without affecting the impurity decrease.
                for tree_num_other_ in range(len(self.trees_)):
                    if not tree_num_other_ == tree_num_:
                        y_residuals_per_tree[tree_num_] -= y_predictions_per_tree[
                            tree_num_other_
                        ]

            # recompute all impurities + update potential_split children
            potential_splits_new = []
            # each candidate's stump is fit independently of the others, and
            # sklearn's tree builder releases the GIL, so this threads well
            updated_splits = self._fit_candidate_stumps(
                X, potential_splits, y_residuals_per_tree, sample_weight)
            for potential_split, potential_split_updated in zip(
                    potential_splits, updated_splits):

                # need to preserve certain attributes from before (value at this split + is_root)
                # value may change because residuals may have changed, but we want it to store the value from before
                potential_split.setattrs(
                    feature=potential_split_updated.feature,
                    threshold=potential_split_updated.threshold,
                    impurity_reduction=potential_split_updated.impurity_reduction,
                    impurity=potential_split_updated.impurity,
                    left_temp=potential_split_updated.left_temp,
                    right_temp=potential_split_updated.right_temp,
                )

                # this is a valid split
                if potential_split.impurity_reduction is not None:
                    potential_splits_new.append(potential_split)

            # sort so largest impurity reduction comes last (should probs make this a heap later)
            potential_splits = sorted(
                potential_splits_new, key=lambda x: x.impurity_reduction
            )
            if verbose >= 2:
                print(self)
            if self.max_rules is not None and self.complexity_ >= self.max_rules:
                finished = True
                break

        # annotate final tree with node_id and value_sklearn, and prepare importance_data_
        importance_data = []
        for tree_ in self.trees_:
            node_counter = iter(range(0, int(1e06)))

            def _annotate_node(node: Node, X, y, weights, is_classmixin=False):
                #TODO: impurity decrease is correct
                if node is None:
                    return

                # value_sklearn holds weighted class totals, matching what
                # sklearn stores, so that importances and the converted tree
                # both reflect sample_weight
                #TODO: how to handdle for n_outputs> 1?
                if is_classmixin:
                    value_sklearn = np.zeros(self.n_outputs)
                    classes = np.argmax(y, axis=1)
                    for class_idx in np.unique(classes):
                        value_sklearn[class_idx] = weights[classes == class_idx].sum()
                    value_sklearn = value_sklearn.astype(float)

                else:
                    value_sklearn = np.array([weights.sum()], dtype=float)

                node.setattrs(node_id=next(node_counter),
                              value_sklearn=value_sklearn,
                              n_samples_=X.shape[0])

                if node.left is None and node.right is None:
                    # a leaf splits on nothing: its feature is the -2 placeholder,
                    # which indexes the wrong column (or raises, with one feature)
                    return

                idxs_left = X[:, node.feature] <= node.threshold
                _annotate_node(node.left, X[idxs_left], y[idxs_left],
                               weights[idxs_left], is_classmixin)
                _annotate_node(node.right, X[~idxs_left], y[~idxs_left],
                               weights[~idxs_left], is_classmixin)

            annotate_weights = (np.ones(X.shape[0]) if sample_weight is None
                                else np.asarray(sample_weight, dtype=float))
            _annotate_node(tree_, X, y, annotate_weights,
                           isinstance(self, ClassifierMixin))

            # now that the samples per node are known, we can start to compute the importances
            importance_data_tree = np.zeros(self.n_features)

            def _importances(node: Node):
                if node is None or node.left is None:
                    return 0.0

                # value_sklearn is weighted, so these importances are too
                importance_data_tree[node.feature] += (
                    np.sum(node.value_sklearn) * node.impurity
                    - np.sum(node.left.value_sklearn) * node.left.impurity
                    - np.sum(node.right.value_sklearn) * node.right.impurity
                )

                return (
                    np.sum(node.value_sklearn)
                    + _importances(node.left)
                    + _importances(node.right)
                )

            # require the tree to have more than 1 node, otherwise just leave importance_data_tree as zeros
            if 1 < next(node_counter):
                tree_samples = _importances(tree_)
                if tree_samples != 0:
                    importance_data_tree /= tree_samples
                else:
                    importance_data_tree = 0

            importance_data.append(importance_data_tree)

        self.importance_data_ = importance_data
        
        return self

    def _tree_to_str(self, root: Node, prefix=""):
        if root is None:
            return ""
        elif root.threshold is None:
            return ""
        pprefix = prefix + "\t"
        return (
            prefix
            + str(root)
            + "\n"
            + self._tree_to_str(root.left, pprefix)
            + self._tree_to_str(root.right, pprefix)
        )

    def _tree_to_str_with_data(self, X, y, root: Node, prefix=""):
        if root is None:
            return ""
        elif root.threshold is None:
            return ""
        pprefix = prefix + "\t"
        left = X[:, root.feature] <= root.threshold
        return (
            prefix
            + root.print_root(y, isinstance(self, ClassifierMixin), self.n_outputs)
            + "\n"
            + self._tree_to_str_with_data(X[left], y[left], root.left, pprefix)
            + self._tree_to_str_with_data(X[~left],
                                          y[~left], root.right, pprefix)
        )

    def __str__(self):
        if not hasattr(self, "trees_"):
            s = self.__class__.__name__
            s += "("
            s += "max_rules="
            s += repr(self.max_rules)
            s += ", "
            s += "max_trees="
            s += repr(self.max_trees)
            s += ", "
            s += "max_depth="
            s += repr(self.max_depth)
            s += ")"
            return s
        else:
            s = "> ------------------------------\n"
            s += "> FIGS-Fast Interpretable Greedy-Tree Sums:\n"
            s += '> \tPredictions are made by summing the "Val" reached by traversing each tree.\n'
            s += "> \tFor classifiers, a softmax function is then applied to the sum.\n"
            s += "> ------------------------------\n"
            s += "\n\t+\n".join([self._tree_to_str(t) for t in self.trees_])
            if hasattr(self, "feature_names_") and self.feature_names_ is not None:
                for i in range(len(self.feature_names_))[::-1]:
                    s = s.replace(f"X_{i}", self.feature_names_[i])
            return s

    def print_tree(self, X, y, feature_names=None):
        s = "------------\n" + "\n\t+\n".join(
            [self._tree_to_str_with_data(X, y, t) for t in self.trees_]
        )
        if feature_names is None:
            if hasattr(self, "feature_names_") and self.feature_names_ is not None:
                feature_names = self.feature_names_
        if feature_names is not None:
            for i in range(len(feature_names))[::-1]:
                s = s.replace(f"X_{i}", feature_names[i])
        return s

    def predict(self, X, categorical_features=None, by_tree=False):
        categorical_features = self._categorical_features(categorical_features)
        if hasattr(self, "_encoder"):
            X = self._encode_categories(
                X, categorical_features=categorical_features, encoder_name="_encoder")
        X = check_array(check_predict_X(self, X))
        preds = np.zeros((X.shape[0], self.n_outputs, len(self.trees_)))
        for i, figs_tree in enumerate(self.trees_):
            preds[:, :, i] += self._predict_tree(figs_tree, X)
        
        if isinstance(self, RegressorMixin):
            if by_tree:
                return preds
            else:
                if self.n_outputs==1:
                    return np.sum(preds, axis = -1).reshape(-1, )
                return np.sum(preds, axis = -1)
        elif isinstance(self, ClassifierMixin):
            if by_tree:
                return preds
            else:
                preds = np.sum(preds, axis = -1)
                max_indices = np.argmax(preds, axis = 1)

                return np.vectorize(self._class_map.get)(max_indices)

            #TODO: account for non integer classes, FYI self.classes_ comes from check_arguments
#             class_preds = (preds > 0.5).astype(int)
#             return np.array([self.classes_[i] for i in class_preds])

    def _categorical_features(self, categorical_features):
        """Fall back on the categorical features the model was fitted with."""
        if categorical_features is None:
            return getattr(self, 'categorical_features_', None)
        return categorical_features

    def predict_proba(self, X, categorical_features=None, use_clipped_prediction=False):
        """Predict probability for classifiers:
        Default behavior is to constrain the outputs to the range of probabilities, i.e. 0 to 1, with a sigmoid function.
        Set use_clipped_prediction=True to use prior behavior of clipping between 0 and 1 instead.
        """
        categorical_features = self._categorical_features(categorical_features)
        if hasattr(self, "_encoder"):
            X = self._encode_categories(
                X, categorical_features=categorical_features, encoder_name="_encoder")
        X = check_array(check_predict_X(self, X))
        if isinstance(self, RegressorMixin):
            return NotImplemented
        preds = np.zeros((X.shape[0], self.n_outputs))
        for figs_tree in self.trees_:
            preds += self._predict_tree(figs_tree, X)
        if use_clipped_prediction:
            # old behavior, pre v1.3.9
            # constrain to range of probabilities by clipping
            return np.clip(preds, a_min=0.0, a_max=1.0)
        else:
            # constrain to range of probabilities with a softmax (multi-class) or a sigmoid (binary) function
            return softmax(preds, axis = 1)


    def _predict_tree(self, root: Node, X):
        """Predict for a single tree"""

        def _predict_tree_single_point(root: Node, x):
            if root.left is None and root.right is None:
                return root.value
            left = x[root.feature] <= root.threshold
            if left:
                if root.left is None:  # we don't actually have to worry about this case
                    return root.value
                else:
                    return _predict_tree_single_point(root.left, x)
            else:
                if (
                    root.right is None
                ):  # we don't actually have to worry about this case
                    return root.value
                else:
                    return _predict_tree_single_point(root.right, x)

        preds = np.zeros((X.shape[0], self.n_outputs))
        for i in range(X.shape[0]):
            preds[i] = _predict_tree_single_point(root, X[i])
        return preds

    @property
    def feature_importances_(self):
        """Gini impurity-based feature importances"""
        check_is_fitted(self)

        avg_feature_importances = np.mean(
            self.importance_data_, axis=0, dtype=np.float64
        )

        return avg_feature_importances / np.sum(avg_feature_importances)

    def plot(
        self,
        cols=2,
        feature_names=None,
        filename=None,
        label="all",
        impurity=False,
        tree_number=None,
        dpi=150,
        fig_size=None,
    ):
        is_single_tree = len(self.trees_) < 2 or tree_number is not None

        if feature_names is None:
            if hasattr(self, "feature_names_") and self.feature_names_ is not None:
                feature_names = self.feature_names_

        n_plots = int(len(self.trees_)) if tree_number is None else 1
        # lay the trees out over `cols` columns, rather than stacking them all
        # in a single one
        n_cols = 1 if is_single_tree else max(1, min(int(cols), n_plots))
        n_rows = int(np.ceil(n_plots / n_cols))
        fig, axs = plt.subplots(n_rows, n_cols, dpi=dpi, squeeze=False)
        if fig_size is not None:
            fig.set_size_inches(fig_size, fig_size)

        # any trailing cells of the grid hold no tree
        for ax in axs.flat[n_plots:]:
            ax.axis("off")

        n_classes = 1 if isinstance(self, RegressorMixin) else self.n_outputs
        for i in range(n_plots):
            ax = axs.flat[i]
            try:
                dt = extract_sklearn_tree_from_figs(
                    self, i if tree_number is None else tree_number, n_classes
                )
                plot_tree(
                    dt,
                    ax=ax,
                    feature_names=feature_names,
                    label=label,
                    impurity=impurity,
                )
            except IndexError:
                ax.axis("off")
                continue
            ttl = f"Tree {i}" if n_plots > 1 else f"Tree {tree_number}"
            ax.set_title(ttl)
        if filename is not None:
            plt.savefig(filename)
            return
        plt.show()


class FIGSRegressor(RegressorMixin, FIGS):
    ...


class FIGSClassifier(ClassifierMixin, FIGS):

    @property
    def class_map(self):
        return self._class_map

    def decision_function(self, X):
        """Confidence score for the positive class, one value per sample.

        Defined for binary problems only, matching sklearn's convention; it is
        what scorers like roc_auc and wrappers like BaggingClassifier reach for
        before falling back to predict_proba.
        """
        proba = self.predict_proba(X)
        if proba.shape[1] != 2:
            raise AttributeError(
                "decision_function is only defined for binary classification; "
                f"this model was fitted with {proba.shape[1]} classes. "
                "Use predict_proba instead."
            )
        return proba[:, 1]


class FIGSCV(BaseEstimator):
    def __init__(
        self,
        figs,
        n_rules_list: List[int] = [6, 12, 24, 30, 50],
        n_trees_list: List[int] = [5, 10, 15],
        depth_list: List[int] = [3, 4],
        min_impurity_decrease_list: List[float] = [0],
        cv: int = 3,
        scoring=None,
        *args,
        **kwargs,
    ):

        self._figs_class = figs
        # stored unmodified so that the estimator stays sklearn-cloneable
        self.n_rules_list = n_rules_list
        self.n_trees_list = n_trees_list
        self.depth_list = depth_list
        self.min_impurity_decrease_list = min_impurity_decrease_list
        self.cv = cv
        self.scoring = scoring


    def get_rules(self, feature_names=None):
        """Return this model's rules as a DataFrame (see imodels.get_rules)."""
        from imodels.util.get_rules import get_rules
        return get_rules(self, feature_names=feature_names)

    @property
    def feature_importances_(self):
        """Mean decrease in impurity of the selected FIGS model."""
        return self.figs.feature_importances_
    def apply(self, X):
        """Return the leaf each sample reaches (see imodels.util.apply.apply_leaves)."""
        from imodels.util.apply import apply_leaves
        return apply_leaves(self, X)

    def get_params(self, deep=True):
        # defined explicitly because __init__ takes *args/**kwargs, which sklearn's
        # automatic parameter introspection rejects
        return {
            "n_rules_list": self.n_rules_list,
            "n_trees_list": self.n_trees_list,
            "depth_list": self.depth_list,
            "min_impurity_decrease_list": self.min_impurity_decrease_list,
            "cv": self.cv,
            "scoring": self.scoring,
        }

    def set_params(self, **params):
        for key, value in params.items():
            setattr(self, key, value)
        return self

    def fit(self, X, y):
        self.scores_ = []
        for _i, (n_rules, n_trees, depth, min_impurity_decrease) in enumerate(itertools.product(*[self.n_rules_list, self.n_trees_list, self.depth_list, self.min_impurity_decrease_list])):
            est = self._figs_class(max_rules=n_rules, max_trees=n_trees, max_depth=depth, min_impurity_decrease=min_impurity_decrease)
            cv_scores = cross_val_score(est, X, y, cv=self.cv, scoring=self.scoring)
            mean_score = np.mean(cv_scores)
            if len(self.scores_) == 0:
                self.figs = est
            elif mean_score > np.max(self.scores_):
                self.figs = est

            self.scores_.append(mean_score)
        self.figs.fit(X=X, y=y)
        self.n_features_in_ = self.figs.n_features_in_
        if hasattr(self.figs, "classes_"):
            self.classes_ = self.figs.classes_
        if hasattr(self.figs, "feature_names_in_"):
            self.feature_names_in_ = self.figs.feature_names_in_
        return self

    def predict_proba(self, X):
        check_is_fitted(self, 'figs')
        return self.figs.predict_proba(X)

    def predict(self, X, by_tree = False):
        check_is_fitted(self, 'figs')
        return self.figs.predict(X, by_tree = by_tree)

    @property
    def max_rules(self):
        return self.figs.max_rules

    @property
    def max_trees(self):
        return self.figs.max_trees
    
    @property
    def max_depth(self):
        return self.figs.max_depth
    
    @property
    def min_impurity_decrease(self):
        return self.figs.min_impurity_decrease
    
    @property
    def trees_(self):
        return self.figs.trees_


class FIGSRegressorCV(RegressorMixin, FIGSCV):
    def __init__(
        self,
        n_rules_list: List[int] = [6, 12, 24, 30, 50],
        n_trees_list: List[int] = [5, 10, 15],
        depth_list: List[int] = [3, 4],
        min_impurity_decrease_list: List[float] = [0],
        cv: int = 3,
        scoring="r2",
        *args,
        **kwargs,
    ):
        super(FIGSRegressorCV, self).__init__(
            figs=FIGSRegressor,
            n_rules_list=n_rules_list,
            n_trees_list=n_trees_list,
            depth_list=depth_list,
            min_impurity_decrease_list=min_impurity_decrease_list,
            cv=cv,
            scoring=scoring,
            *args,
            **kwargs,
        )


#TODO: handle annoying CV errors
class FIGSClassifierCV(ClassifierMixin, FIGSCV):
    def __init__(
        self,
        n_rules_list: List[int] = [6, 12, 24, 30, 50],
        n_trees_list: List[int] = [5, 10, 15],
        depth_list: List[int] = [3, 4],
        min_impurity_decrease_list: List[float] = [0],
        cv: int = 3,
        scoring="accuracy",
        *args,
        **kwargs,
    ):
        super(FIGSClassifierCV, self).__init__(
            figs=FIGSClassifier,
            n_rules_list=n_rules_list,
            n_trees_list=n_trees_list,
            depth_list=depth_list,
            min_impurity_decrease_list=min_impurity_decrease_list,
            cv=cv,
            scoring=scoring,
            *args,
            **kwargs,
        )
        

        
# class FIGSHydraRegressor():
#     def __init__(
#         self,
#         max_rules: int = 12,
#         max_trees: int = None,
#         min_impurity_decrease: float = 0.0,
#         random_state=None,
#         max_features: str = None,
#         max_depth: int = None
#     ):
        
#         self.max_rules = max_rules
#         self.max_trees = max_trees
#         self.min_impurity_decrease = min_impurity_decrease
#         self.random_state = random_state
#         self.max_features = max_features
#         self.max_depth = max_depth
#         self.estimators = []
        
#     def fit(self, X, y):
#         if isinstance(y, pd.DataFrame):
#             y = y.to_numpy()
#         for i in range(y.shape[1]):
#             est = FIGSRegressor(max_rules=self.max_rules, max_trees=self.max_trees, max_depth=self.max_depth)
#             est.fit(X, y[:, i].reshape(-1, 1))
#             self.estimators.append(est)
    
#     def predict(self, X):
#         return np.array([est.predict(X) for est in self.estimators]).T.squeeze(0)

Classes

class FIGS (max_rules: int = 12, max_trees: int = None, min_impurity_decrease: float = 0.0, random_state=None, max_features: str = None, max_depth: int = None, class_weight=None, verbose: int = 0, n_jobs: int = None)

FIGS (sum of trees) classifier. Fast Interpretable Greedy-Tree Sums (FIGS) is an algorithm for fitting concise rule-based models. Specifically, FIGS generalizes CART to simultaneously grow a flexible number of trees in a summation. The total number of splits across all the trees can be restricted by a pre-specified threshold, keeping the model interpretable. Experiments across real-world datasets show that FIGS achieves state-of-the-art prediction performance when restricted to just a few splits (e.g. less than 20). https://arxiv.org/abs/2201.11931

Params

max_rules: int Max total number of rules across all trees max_trees: int Max total number of trees min_impurity_decrease: float A node will be split if this split induces a decrease of the impurity greater than or equal to this value. max_features The number of features to consider when looking for the best split (see https://scikit-learn.org/stable/modules/generated/sklearn.ensemble.RandomForestClassifier.html) n_jobs: int, default=None Number of threads used to evaluate candidate splits, which are independent of one another. None means 1; -1 uses all processors. Only helps once there are several candidates to compare, i.e. on larger datasets or deeper models. verbose: int, default=0 Controls progress reporting while fitting. 0 is silent; 1 reports each rule as it is added, with the running total; 2 also prints the model after every rule. Can be overridden per call via fit(verbose=…). class_weight: dict, list of dict or "balanced", default=None Classification only. Weights associated with classes, in the form {class_label: weight}. "balanced" weights each class by n_samples / (n_classes * np.bincount(y)), so that rare classes count as much as common ones. Combined multiplicatively with sample_weight when both are given.

Expand source code
class FIGS(BaseEstimator):
    """FIGS (sum of trees) classifier.
    Fast Interpretable Greedy-Tree Sums (FIGS) is an algorithm for fitting concise rule-based models.
    Specifically, FIGS generalizes CART to simultaneously grow a flexible number of trees in a summation.
    The total number of splits across all the trees can be restricted by a pre-specified threshold, keeping the model interpretable.
    Experiments across real-world datasets show that FIGS achieves state-of-the-art prediction performance when restricted to just a few splits (e.g. less than 20).
    https://arxiv.org/abs/2201.11931
    """

    def __init__(
        self,
        max_rules: int = 12,
        max_trees: int = None,
        min_impurity_decrease: float = 0.0,
        random_state=None,
        max_features: str = None,
        max_depth: int = None,
        class_weight=None,
        verbose: int = 0,
        n_jobs: int = None,
    ):
        """
        Params
        ------
        max_rules: int
            Max total number of rules across all trees
        max_trees: int
            Max total number of trees
        min_impurity_decrease: float
            A node will be split if this split induces a decrease of the impurity greater than or equal to this value.
        max_features
            The number of features to consider when looking for the best split (see https://scikit-learn.org/stable/modules/generated/sklearn.ensemble.RandomForestClassifier.html)
        n_jobs: int, default=None
            Number of threads used to evaluate candidate splits, which are
            independent of one another. None means 1; -1 uses all processors.
            Only helps once there are several candidates to compare, i.e. on
            larger datasets or deeper models.
        verbose: int, default=0
            Controls progress reporting while fitting. 0 is silent; 1 reports each
            rule as it is added, with the running total; 2 also prints the model
            after every rule. Can be overridden per call via fit(verbose=...).
        class_weight: dict, list of dict or "balanced", default=None
            Classification only. Weights associated with classes, in the form
            {class_label: weight}. "balanced" weights each class by
            n_samples / (n_classes * np.bincount(y)), so that rare classes count
            as much as common ones. Combined multiplicatively with sample_weight
            when both are given.
        """
        super().__init__()
        self.max_rules = max_rules
        self.max_trees = max_trees
        self.min_impurity_decrease = min_impurity_decrease
        self.random_state = random_state
        self.max_features = max_features
        self.max_depth = max_depth
        self.class_weight = class_weight
        self.verbose = verbose
        self.n_jobs = n_jobs
        self.n_outputs = None
        self.need_to_reshape = False


    def get_rules(self, feature_names=None):
        """Return this model's rules as a DataFrame (see imodels.get_rules)."""
        from imodels.util.get_rules import get_rules
        return get_rules(self, feature_names=feature_names)

    def apply(self, X):
        """Return the leaf each sample reaches (see imodels.util.apply.apply_leaves)."""
        from imodels.util.apply import apply_leaves
        return apply_leaves(self, X)
    def _fit_candidate_stumps(self, X, potential_splits, y_residuals_per_tree,
                              sample_weight):
        """Re-fit the stump for every candidate split, in parallel if asked."""
        def fit_stump(potential_split):
            return self._construct_node_with_stump(
                X=X,
                y=y_residuals_per_tree[potential_split.tree_num],
                idxs=potential_split.idxs,
                tree_num=potential_split.tree_num,
                sample_weight=sample_weight,
                max_features=self.max_features,
                depth=potential_split.depth + 1,
            )

        n_jobs = 1 if self.n_jobs is None else self.n_jobs
        if n_jobs == 1 or len(potential_splits) < 2:
            return [fit_stump(split) for split in potential_splits]
        return Parallel(n_jobs=n_jobs, backend="threading")(
            delayed(fit_stump)(split) for split in potential_splits)

    def _apply_class_weight(self, y, sample_weight):
        """Fold class_weight into sample_weight, which the splits already honor."""
        if self.class_weight is None:
            return sample_weight
        if not isinstance(self, ClassifierMixin):
            raise ValueError(
                "class_weight is only meaningful for classification; "
                f"{type(self).__name__} is a regressor. Use sample_weight instead."
            )

        class_based = compute_sample_weight(self.class_weight, np.ravel(y))
        if sample_weight is None:
            return class_based
        return np.asarray(sample_weight, dtype=float) * class_based

    def _construct_node_with_stump(
        self,
        X,
        y,
        idxs,
        tree_num,
        sample_weight=None,
        compare_nodes_with_sample_weight=True,
        max_features=None,
        depth=None,
    ):
        """
        Params
        ------
        compare_nodes_with_sample_weight: Deprecated
            If this is set to true and sample_weight is passed, use sample_weight to compare nodes
            Otherwise, use sample_weight only for picking a split given a particular node
        """

        # array indices
        SPLIT = 0
        LEFT = 1
        RIGHT = 2

        # fit stump
        stump = tree.DecisionTreeRegressor(
            max_depth=1, max_features=max_features)
        sweight = None
        if sample_weight is not None:
            sweight = sample_weight[idxs]
        stump.fit(X[idxs], y[idxs], sample_weight=sweight)

        # these are all arrays, arr[0] is split node
        # note: -2 is dummy
        feature = stump.tree_.feature
        threshold = stump.tree_.threshold

        impurity = stump.tree_.impurity
        n_node_samples = stump.tree_.n_node_samples
        value = stump.tree_.value

        # no split
        if len(feature) == 1:
            # print('no split found!', idxs.sum(), impurity, feature)
            return Node(
                idxs=idxs,
                value=value[SPLIT],
                tree_num=tree_num,
                feature=feature[SPLIT],
                threshold=threshold[SPLIT],
                impurity=impurity[SPLIT],
                impurity_reduction=None,
                depth=depth,
            )

        # manage sample weights
        idxs_split = X[:, feature[SPLIT]] <= threshold[SPLIT]
        idxs_left = idxs_split & idxs
        idxs_right = ~idxs_split & idxs
        if sample_weight is None:
            n_node_samples_left = n_node_samples[LEFT]
            n_node_samples_right = n_node_samples[RIGHT]
        else:
            n_node_samples_left = sample_weight[idxs_left].sum()
            n_node_samples_right = sample_weight[idxs_right].sum()
        n_node_samples_split = n_node_samples_left + n_node_samples_right

        # calculate impurity
        impurity_reduction = (
            impurity[SPLIT]
            - impurity[LEFT] * n_node_samples_left / n_node_samples_split
            - impurity[RIGHT] * n_node_samples_right / n_node_samples_split
        ) * n_node_samples_split

        node_split = Node(
            idxs=idxs,
            value=value[SPLIT],
            tree_num=tree_num,
            feature=feature[SPLIT],
            threshold=threshold[SPLIT],
            impurity=impurity[SPLIT],
            impurity_reduction=impurity_reduction,
            depth=depth,
        )
        # print('\t>>>', node_split, 'impurity', impurity, 'num_pts', idxs.sum(), 'imp_reduc', impurity_reduction)

        # manage children
        node_left = Node(
            idxs=idxs_left,
            value=value[LEFT],
            impurity=impurity[LEFT],
            tree_num=tree_num,
            depth=depth+1,
        )
        node_right = Node(
            idxs=idxs_right,
            value=value[RIGHT],
            impurity=impurity[RIGHT],
            tree_num=tree_num,
            depth=depth+1,
        )
        node_split.setattrs(
            left_temp=node_left,
            right_temp=node_right,
        )
        return node_split

    def _encode_categories(self, X, categorical_features, encoder_name):
        """Apply the encoder stored under encoder_name (fitted during fit) to X."""
        return encode_categories(X, categorical_features, getattr(self, encoder_name))

    def fit(
        self,
        X,
        y=None,
        feature_names=None,
        verbose=None,
        sample_weight=None,
        categorical_features=None,
    ):
        """
        Params
        ------
        _sample_weight: array-like of shape (n_samples,), default=None
            Sample weights. If None, then samples are equally weighted.
            Splits that would create child nodes with net zero or negative weight
            are ignored while searching for a split in each node.
        """
        # fit(verbose=...) still wins, so existing callers are unaffected
        verbose = int(self.verbose if verbose is None else verbose)

        # remembered so that predict/predict_proba don't need them passed again
        self.categorical_features_ = categorical_features
        if categorical_features is not None:
            X, self._encoder = encode_categories(X, categorical_features)

        sample_weight = self._apply_class_weight(y, sample_weight)

        if hasattr(y, 'values'):
            y = y.values
        # y may still be a plain list here, which has no .shape
        y = np.asarray(y)
        if len(y.shape) == 1:
            y = y.reshape(-1, 1)
        
        if isinstance(self, ClassifierMixin):
            assert y.shape[1] == 1, "FIGSClassifier requires a 1-dimensional input"
            if hasattr(y, 'name'):
                class_name = y.name
            elif hasattr(y, 'columns'):
                class_name = y.columns[0]
            else:
                class_name = 'class'

            #self.classes_, y = np.unique(y, return_inverse=True)
            self.classes_ = np.unique(y)
            y, self._class_encoder = encode_categories(
                pd.DataFrame(y, columns=[class_name]), [class_name])
            self.Y = y
            self._class_map = {i:c for i, c in zip(np.arange(0, y.shape[1]), self._class_encoder.inverse_transform(np.eye(y.shape[1])).reshape(-1, ))}
            
        X, y, feature_names = check_fit_arguments(self, X, y, feature_names, True, False)
        self.Y = y
        self.n_outputs = y.shape[1]
        self.n_features = X.shape[1]
        
        if sample_weight is not None:
            sample_weight = _check_sample_weight(sample_weight, X)
            
        self.trees_ = []  # list of the root nodes of added trees
        self.complexity_ = 0  # tracks the number of rules in the model
        y_predictions_per_tree = {}  # predictions for each tree
        y_residuals_per_tree = {}  # based on predictions above

        # set up initial potential_splits
        # everything in potential_splits either is_root (so it can be added directly to self.trees_)
        # or it is a child of a root node that has already been added
        idxs = np.ones(X.shape[0], dtype=bool)
        node_init = self._construct_node_with_stump(
            X=X,
            y=y,
            idxs=idxs,
            tree_num=-1,
            sample_weight=sample_weight,
            max_features=self.max_features,
            depth=0,
        )
        potential_splits = [node_init]
        for node in potential_splits:
            node.setattrs(is_root=True)
        potential_splits = sorted(
            potential_splits, key=lambda x: x.impurity_reduction)

        # start the greedy fitting algorithm
        finished = False
        while len(potential_splits) > 0 and not finished:
            # print('potential_splits', [str(s) for s in potential_splits])
            # get node with max impurity_reduction (since it's sorted)
            split_node = potential_splits.pop()

            # don't split on node.
            # impurity_reduction is None when the stump found no valid split,
            # which happens when y is constant over the node -- there is nothing
            # left to fit, so stop rather than compare None to a float
            if (split_node.impurity_reduction is None
                    or split_node.impurity_reduction < self.min_impurity_decrease):
                # nothing worth splitting on. If that happened before any tree
                # was grown, keep this node as a single leaf: predictions are a
                # sum over trees, so with none at all the model would return 0
                # whatever y is, rather than y's mean.
                if split_node.is_root and not self.trees_:
                    split_node.setattrs(tree_num=0, left=None, right=None)
                    self.trees_.append(split_node)
                finished = True
                break
            elif (
                split_node.is_root
                and self.max_trees is not None
                and len(self.trees_) >= self.max_trees
            ):
                # If the node is the root of a new tree and we have reached self.max_trees,
                # don't split on it, but allow later splits to continue growing existing trees
                continue
            elif (
                self.max_depth is not None
                and split_node.depth > self.max_depth
            ):
                # If the node is deeper than self.max_depth,
                # don't split on it, but allow algorithm to continue
                continue

            # split on node
            self.complexity_ += 1

            # if added a tree root
            if split_node.is_root:
                # start a new tree
                self.trees_.append(split_node)

                # update tree_num
                for node_ in [split_node, split_node.left_temp, split_node.right_temp]:
                    if node_ is not None:
                        node_.tree_num = len(self.trees_) - 1

                # add new root potential node
                node_new_root = Node(
                    is_root=True, idxs=np.ones(X.shape[0], dtype=bool), tree_num=-1, depth=0,
                )
                potential_splits.append(node_new_root)

            # add children to potential splits
            # assign left_temp, right_temp to be proper children
            # (basically adds them to tree in predict method)
            split_node.setattrs(left=split_node.left_temp,
                                right=split_node.right_temp)

            # add children to potential_splits
            potential_splits.append(split_node.left)
            potential_splits.append(split_node.right)

            if verbose >= 1:
                # reported after the bookkeeping above, so the counts are final
                budget = '' if self.max_rules is None else f'/{self.max_rules}'
                condition = (f"X_{split_node.feature} <= {split_node.threshold:0.3f}"
                             if split_node.feature is not None else str(split_node))
                print(f"rule {self.complexity_}{budget} "
                      f"({len(self.trees_)} tree(s)): {condition}")

            # update predictions for altered tree
            for tree_num_ in range(len(self.trees_)):
                y_predictions_per_tree[tree_num_] = self._predict_tree(
                    self.trees_[tree_num_], X
                )
            # dummy 0 preds for possible new trees
            y_predictions_per_tree[-1] = np.zeros((X.shape[0], self.n_outputs))

            # update residuals for each tree
            # -1 is key for potential new tree
            for tree_num_ in list(range(len(self.trees_))) + [-1]:
                y_residuals_per_tree[tree_num_] = deepcopy(y)

                # subtract predictions of all other trees
                # Since the current tree makes a constant prediction over the node being split,
                # one may ignore its contributions to the residuals without affecting the impurity decrease.
                for tree_num_other_ in range(len(self.trees_)):
                    if not tree_num_other_ == tree_num_:
                        y_residuals_per_tree[tree_num_] -= y_predictions_per_tree[
                            tree_num_other_
                        ]

            # recompute all impurities + update potential_split children
            potential_splits_new = []
            # each candidate's stump is fit independently of the others, and
            # sklearn's tree builder releases the GIL, so this threads well
            updated_splits = self._fit_candidate_stumps(
                X, potential_splits, y_residuals_per_tree, sample_weight)
            for potential_split, potential_split_updated in zip(
                    potential_splits, updated_splits):

                # need to preserve certain attributes from before (value at this split + is_root)
                # value may change because residuals may have changed, but we want it to store the value from before
                potential_split.setattrs(
                    feature=potential_split_updated.feature,
                    threshold=potential_split_updated.threshold,
                    impurity_reduction=potential_split_updated.impurity_reduction,
                    impurity=potential_split_updated.impurity,
                    left_temp=potential_split_updated.left_temp,
                    right_temp=potential_split_updated.right_temp,
                )

                # this is a valid split
                if potential_split.impurity_reduction is not None:
                    potential_splits_new.append(potential_split)

            # sort so largest impurity reduction comes last (should probs make this a heap later)
            potential_splits = sorted(
                potential_splits_new, key=lambda x: x.impurity_reduction
            )
            if verbose >= 2:
                print(self)
            if self.max_rules is not None and self.complexity_ >= self.max_rules:
                finished = True
                break

        # annotate final tree with node_id and value_sklearn, and prepare importance_data_
        importance_data = []
        for tree_ in self.trees_:
            node_counter = iter(range(0, int(1e06)))

            def _annotate_node(node: Node, X, y, weights, is_classmixin=False):
                #TODO: impurity decrease is correct
                if node is None:
                    return

                # value_sklearn holds weighted class totals, matching what
                # sklearn stores, so that importances and the converted tree
                # both reflect sample_weight
                #TODO: how to handdle for n_outputs> 1?
                if is_classmixin:
                    value_sklearn = np.zeros(self.n_outputs)
                    classes = np.argmax(y, axis=1)
                    for class_idx in np.unique(classes):
                        value_sklearn[class_idx] = weights[classes == class_idx].sum()
                    value_sklearn = value_sklearn.astype(float)

                else:
                    value_sklearn = np.array([weights.sum()], dtype=float)

                node.setattrs(node_id=next(node_counter),
                              value_sklearn=value_sklearn,
                              n_samples_=X.shape[0])

                if node.left is None and node.right is None:
                    # a leaf splits on nothing: its feature is the -2 placeholder,
                    # which indexes the wrong column (or raises, with one feature)
                    return

                idxs_left = X[:, node.feature] <= node.threshold
                _annotate_node(node.left, X[idxs_left], y[idxs_left],
                               weights[idxs_left], is_classmixin)
                _annotate_node(node.right, X[~idxs_left], y[~idxs_left],
                               weights[~idxs_left], is_classmixin)

            annotate_weights = (np.ones(X.shape[0]) if sample_weight is None
                                else np.asarray(sample_weight, dtype=float))
            _annotate_node(tree_, X, y, annotate_weights,
                           isinstance(self, ClassifierMixin))

            # now that the samples per node are known, we can start to compute the importances
            importance_data_tree = np.zeros(self.n_features)

            def _importances(node: Node):
                if node is None or node.left is None:
                    return 0.0

                # value_sklearn is weighted, so these importances are too
                importance_data_tree[node.feature] += (
                    np.sum(node.value_sklearn) * node.impurity
                    - np.sum(node.left.value_sklearn) * node.left.impurity
                    - np.sum(node.right.value_sklearn) * node.right.impurity
                )

                return (
                    np.sum(node.value_sklearn)
                    + _importances(node.left)
                    + _importances(node.right)
                )

            # require the tree to have more than 1 node, otherwise just leave importance_data_tree as zeros
            if 1 < next(node_counter):
                tree_samples = _importances(tree_)
                if tree_samples != 0:
                    importance_data_tree /= tree_samples
                else:
                    importance_data_tree = 0

            importance_data.append(importance_data_tree)

        self.importance_data_ = importance_data
        
        return self

    def _tree_to_str(self, root: Node, prefix=""):
        if root is None:
            return ""
        elif root.threshold is None:
            return ""
        pprefix = prefix + "\t"
        return (
            prefix
            + str(root)
            + "\n"
            + self._tree_to_str(root.left, pprefix)
            + self._tree_to_str(root.right, pprefix)
        )

    def _tree_to_str_with_data(self, X, y, root: Node, prefix=""):
        if root is None:
            return ""
        elif root.threshold is None:
            return ""
        pprefix = prefix + "\t"
        left = X[:, root.feature] <= root.threshold
        return (
            prefix
            + root.print_root(y, isinstance(self, ClassifierMixin), self.n_outputs)
            + "\n"
            + self._tree_to_str_with_data(X[left], y[left], root.left, pprefix)
            + self._tree_to_str_with_data(X[~left],
                                          y[~left], root.right, pprefix)
        )

    def __str__(self):
        if not hasattr(self, "trees_"):
            s = self.__class__.__name__
            s += "("
            s += "max_rules="
            s += repr(self.max_rules)
            s += ", "
            s += "max_trees="
            s += repr(self.max_trees)
            s += ", "
            s += "max_depth="
            s += repr(self.max_depth)
            s += ")"
            return s
        else:
            s = "> ------------------------------\n"
            s += "> FIGS-Fast Interpretable Greedy-Tree Sums:\n"
            s += '> \tPredictions are made by summing the "Val" reached by traversing each tree.\n'
            s += "> \tFor classifiers, a softmax function is then applied to the sum.\n"
            s += "> ------------------------------\n"
            s += "\n\t+\n".join([self._tree_to_str(t) for t in self.trees_])
            if hasattr(self, "feature_names_") and self.feature_names_ is not None:
                for i in range(len(self.feature_names_))[::-1]:
                    s = s.replace(f"X_{i}", self.feature_names_[i])
            return s

    def print_tree(self, X, y, feature_names=None):
        s = "------------\n" + "\n\t+\n".join(
            [self._tree_to_str_with_data(X, y, t) for t in self.trees_]
        )
        if feature_names is None:
            if hasattr(self, "feature_names_") and self.feature_names_ is not None:
                feature_names = self.feature_names_
        if feature_names is not None:
            for i in range(len(feature_names))[::-1]:
                s = s.replace(f"X_{i}", feature_names[i])
        return s

    def predict(self, X, categorical_features=None, by_tree=False):
        categorical_features = self._categorical_features(categorical_features)
        if hasattr(self, "_encoder"):
            X = self._encode_categories(
                X, categorical_features=categorical_features, encoder_name="_encoder")
        X = check_array(check_predict_X(self, X))
        preds = np.zeros((X.shape[0], self.n_outputs, len(self.trees_)))
        for i, figs_tree in enumerate(self.trees_):
            preds[:, :, i] += self._predict_tree(figs_tree, X)
        
        if isinstance(self, RegressorMixin):
            if by_tree:
                return preds
            else:
                if self.n_outputs==1:
                    return np.sum(preds, axis = -1).reshape(-1, )
                return np.sum(preds, axis = -1)
        elif isinstance(self, ClassifierMixin):
            if by_tree:
                return preds
            else:
                preds = np.sum(preds, axis = -1)
                max_indices = np.argmax(preds, axis = 1)

                return np.vectorize(self._class_map.get)(max_indices)

            #TODO: account for non integer classes, FYI self.classes_ comes from check_arguments
#             class_preds = (preds > 0.5).astype(int)
#             return np.array([self.classes_[i] for i in class_preds])

    def _categorical_features(self, categorical_features):
        """Fall back on the categorical features the model was fitted with."""
        if categorical_features is None:
            return getattr(self, 'categorical_features_', None)
        return categorical_features

    def predict_proba(self, X, categorical_features=None, use_clipped_prediction=False):
        """Predict probability for classifiers:
        Default behavior is to constrain the outputs to the range of probabilities, i.e. 0 to 1, with a sigmoid function.
        Set use_clipped_prediction=True to use prior behavior of clipping between 0 and 1 instead.
        """
        categorical_features = self._categorical_features(categorical_features)
        if hasattr(self, "_encoder"):
            X = self._encode_categories(
                X, categorical_features=categorical_features, encoder_name="_encoder")
        X = check_array(check_predict_X(self, X))
        if isinstance(self, RegressorMixin):
            return NotImplemented
        preds = np.zeros((X.shape[0], self.n_outputs))
        for figs_tree in self.trees_:
            preds += self._predict_tree(figs_tree, X)
        if use_clipped_prediction:
            # old behavior, pre v1.3.9
            # constrain to range of probabilities by clipping
            return np.clip(preds, a_min=0.0, a_max=1.0)
        else:
            # constrain to range of probabilities with a softmax (multi-class) or a sigmoid (binary) function
            return softmax(preds, axis = 1)


    def _predict_tree(self, root: Node, X):
        """Predict for a single tree"""

        def _predict_tree_single_point(root: Node, x):
            if root.left is None and root.right is None:
                return root.value
            left = x[root.feature] <= root.threshold
            if left:
                if root.left is None:  # we don't actually have to worry about this case
                    return root.value
                else:
                    return _predict_tree_single_point(root.left, x)
            else:
                if (
                    root.right is None
                ):  # we don't actually have to worry about this case
                    return root.value
                else:
                    return _predict_tree_single_point(root.right, x)

        preds = np.zeros((X.shape[0], self.n_outputs))
        for i in range(X.shape[0]):
            preds[i] = _predict_tree_single_point(root, X[i])
        return preds

    @property
    def feature_importances_(self):
        """Gini impurity-based feature importances"""
        check_is_fitted(self)

        avg_feature_importances = np.mean(
            self.importance_data_, axis=0, dtype=np.float64
        )

        return avg_feature_importances / np.sum(avg_feature_importances)

    def plot(
        self,
        cols=2,
        feature_names=None,
        filename=None,
        label="all",
        impurity=False,
        tree_number=None,
        dpi=150,
        fig_size=None,
    ):
        is_single_tree = len(self.trees_) < 2 or tree_number is not None

        if feature_names is None:
            if hasattr(self, "feature_names_") and self.feature_names_ is not None:
                feature_names = self.feature_names_

        n_plots = int(len(self.trees_)) if tree_number is None else 1
        # lay the trees out over `cols` columns, rather than stacking them all
        # in a single one
        n_cols = 1 if is_single_tree else max(1, min(int(cols), n_plots))
        n_rows = int(np.ceil(n_plots / n_cols))
        fig, axs = plt.subplots(n_rows, n_cols, dpi=dpi, squeeze=False)
        if fig_size is not None:
            fig.set_size_inches(fig_size, fig_size)

        # any trailing cells of the grid hold no tree
        for ax in axs.flat[n_plots:]:
            ax.axis("off")

        n_classes = 1 if isinstance(self, RegressorMixin) else self.n_outputs
        for i in range(n_plots):
            ax = axs.flat[i]
            try:
                dt = extract_sklearn_tree_from_figs(
                    self, i if tree_number is None else tree_number, n_classes
                )
                plot_tree(
                    dt,
                    ax=ax,
                    feature_names=feature_names,
                    label=label,
                    impurity=impurity,
                )
            except IndexError:
                ax.axis("off")
                continue
            ttl = f"Tree {i}" if n_plots > 1 else f"Tree {tree_number}"
            ax.set_title(ttl)
        if filename is not None:
            plt.savefig(filename)
            return
        plt.show()

Ancestors

  • sklearn.base.BaseEstimator
  • sklearn.utils._repr_html.base.ReprHTMLMixin
  • sklearn.utils._repr_html.base._HTMLDocumentationLinkMixin
  • sklearn.utils._metadata_requests._MetadataRequester

Subclasses

Instance variables

var feature_importances_

Gini impurity-based feature importances

Expand source code
@property
def feature_importances_(self):
    """Gini impurity-based feature importances"""
    check_is_fitted(self)

    avg_feature_importances = np.mean(
        self.importance_data_, axis=0, dtype=np.float64
    )

    return avg_feature_importances / np.sum(avg_feature_importances)

Methods

def apply(self, X)

Return the leaf each sample reaches (see imodels.util.apply.apply_leaves).

Expand source code
def apply(self, X):
    """Return the leaf each sample reaches (see imodels.util.apply.apply_leaves)."""
    from imodels.util.apply import apply_leaves
    return apply_leaves(self, X)
def fit(self, X, y=None, feature_names=None, verbose=None, sample_weight=None, categorical_features=None)

Params

_sample_weight: array-like of shape (n_samples,), default=None Sample weights. If None, then samples are equally weighted. Splits that would create child nodes with net zero or negative weight are ignored while searching for a split in each node.

Expand source code
def fit(
    self,
    X,
    y=None,
    feature_names=None,
    verbose=None,
    sample_weight=None,
    categorical_features=None,
):
    """
    Params
    ------
    _sample_weight: array-like of shape (n_samples,), default=None
        Sample weights. If None, then samples are equally weighted.
        Splits that would create child nodes with net zero or negative weight
        are ignored while searching for a split in each node.
    """
    # fit(verbose=...) still wins, so existing callers are unaffected
    verbose = int(self.verbose if verbose is None else verbose)

    # remembered so that predict/predict_proba don't need them passed again
    self.categorical_features_ = categorical_features
    if categorical_features is not None:
        X, self._encoder = encode_categories(X, categorical_features)

    sample_weight = self._apply_class_weight(y, sample_weight)

    if hasattr(y, 'values'):
        y = y.values
    # y may still be a plain list here, which has no .shape
    y = np.asarray(y)
    if len(y.shape) == 1:
        y = y.reshape(-1, 1)
    
    if isinstance(self, ClassifierMixin):
        assert y.shape[1] == 1, "FIGSClassifier requires a 1-dimensional input"
        if hasattr(y, 'name'):
            class_name = y.name
        elif hasattr(y, 'columns'):
            class_name = y.columns[0]
        else:
            class_name = 'class'

        #self.classes_, y = np.unique(y, return_inverse=True)
        self.classes_ = np.unique(y)
        y, self._class_encoder = encode_categories(
            pd.DataFrame(y, columns=[class_name]), [class_name])
        self.Y = y
        self._class_map = {i:c for i, c in zip(np.arange(0, y.shape[1]), self._class_encoder.inverse_transform(np.eye(y.shape[1])).reshape(-1, ))}
        
    X, y, feature_names = check_fit_arguments(self, X, y, feature_names, True, False)
    self.Y = y
    self.n_outputs = y.shape[1]
    self.n_features = X.shape[1]
    
    if sample_weight is not None:
        sample_weight = _check_sample_weight(sample_weight, X)
        
    self.trees_ = []  # list of the root nodes of added trees
    self.complexity_ = 0  # tracks the number of rules in the model
    y_predictions_per_tree = {}  # predictions for each tree
    y_residuals_per_tree = {}  # based on predictions above

    # set up initial potential_splits
    # everything in potential_splits either is_root (so it can be added directly to self.trees_)
    # or it is a child of a root node that has already been added
    idxs = np.ones(X.shape[0], dtype=bool)
    node_init = self._construct_node_with_stump(
        X=X,
        y=y,
        idxs=idxs,
        tree_num=-1,
        sample_weight=sample_weight,
        max_features=self.max_features,
        depth=0,
    )
    potential_splits = [node_init]
    for node in potential_splits:
        node.setattrs(is_root=True)
    potential_splits = sorted(
        potential_splits, key=lambda x: x.impurity_reduction)

    # start the greedy fitting algorithm
    finished = False
    while len(potential_splits) > 0 and not finished:
        # print('potential_splits', [str(s) for s in potential_splits])
        # get node with max impurity_reduction (since it's sorted)
        split_node = potential_splits.pop()

        # don't split on node.
        # impurity_reduction is None when the stump found no valid split,
        # which happens when y is constant over the node -- there is nothing
        # left to fit, so stop rather than compare None to a float
        if (split_node.impurity_reduction is None
                or split_node.impurity_reduction < self.min_impurity_decrease):
            # nothing worth splitting on. If that happened before any tree
            # was grown, keep this node as a single leaf: predictions are a
            # sum over trees, so with none at all the model would return 0
            # whatever y is, rather than y's mean.
            if split_node.is_root and not self.trees_:
                split_node.setattrs(tree_num=0, left=None, right=None)
                self.trees_.append(split_node)
            finished = True
            break
        elif (
            split_node.is_root
            and self.max_trees is not None
            and len(self.trees_) >= self.max_trees
        ):
            # If the node is the root of a new tree and we have reached self.max_trees,
            # don't split on it, but allow later splits to continue growing existing trees
            continue
        elif (
            self.max_depth is not None
            and split_node.depth > self.max_depth
        ):
            # If the node is deeper than self.max_depth,
            # don't split on it, but allow algorithm to continue
            continue

        # split on node
        self.complexity_ += 1

        # if added a tree root
        if split_node.is_root:
            # start a new tree
            self.trees_.append(split_node)

            # update tree_num
            for node_ in [split_node, split_node.left_temp, split_node.right_temp]:
                if node_ is not None:
                    node_.tree_num = len(self.trees_) - 1

            # add new root potential node
            node_new_root = Node(
                is_root=True, idxs=np.ones(X.shape[0], dtype=bool), tree_num=-1, depth=0,
            )
            potential_splits.append(node_new_root)

        # add children to potential splits
        # assign left_temp, right_temp to be proper children
        # (basically adds them to tree in predict method)
        split_node.setattrs(left=split_node.left_temp,
                            right=split_node.right_temp)

        # add children to potential_splits
        potential_splits.append(split_node.left)
        potential_splits.append(split_node.right)

        if verbose >= 1:
            # reported after the bookkeeping above, so the counts are final
            budget = '' if self.max_rules is None else f'/{self.max_rules}'
            condition = (f"X_{split_node.feature} <= {split_node.threshold:0.3f}"
                         if split_node.feature is not None else str(split_node))
            print(f"rule {self.complexity_}{budget} "
                  f"({len(self.trees_)} tree(s)): {condition}")

        # update predictions for altered tree
        for tree_num_ in range(len(self.trees_)):
            y_predictions_per_tree[tree_num_] = self._predict_tree(
                self.trees_[tree_num_], X
            )
        # dummy 0 preds for possible new trees
        y_predictions_per_tree[-1] = np.zeros((X.shape[0], self.n_outputs))

        # update residuals for each tree
        # -1 is key for potential new tree
        for tree_num_ in list(range(len(self.trees_))) + [-1]:
            y_residuals_per_tree[tree_num_] = deepcopy(y)

            # subtract predictions of all other trees
            # Since the current tree makes a constant prediction over the node being split,
            # one may ignore its contributions to the residuals without affecting the impurity decrease.
            for tree_num_other_ in range(len(self.trees_)):
                if not tree_num_other_ == tree_num_:
                    y_residuals_per_tree[tree_num_] -= y_predictions_per_tree[
                        tree_num_other_
                    ]

        # recompute all impurities + update potential_split children
        potential_splits_new = []
        # each candidate's stump is fit independently of the others, and
        # sklearn's tree builder releases the GIL, so this threads well
        updated_splits = self._fit_candidate_stumps(
            X, potential_splits, y_residuals_per_tree, sample_weight)
        for potential_split, potential_split_updated in zip(
                potential_splits, updated_splits):

            # need to preserve certain attributes from before (value at this split + is_root)
            # value may change because residuals may have changed, but we want it to store the value from before
            potential_split.setattrs(
                feature=potential_split_updated.feature,
                threshold=potential_split_updated.threshold,
                impurity_reduction=potential_split_updated.impurity_reduction,
                impurity=potential_split_updated.impurity,
                left_temp=potential_split_updated.left_temp,
                right_temp=potential_split_updated.right_temp,
            )

            # this is a valid split
            if potential_split.impurity_reduction is not None:
                potential_splits_new.append(potential_split)

        # sort so largest impurity reduction comes last (should probs make this a heap later)
        potential_splits = sorted(
            potential_splits_new, key=lambda x: x.impurity_reduction
        )
        if verbose >= 2:
            print(self)
        if self.max_rules is not None and self.complexity_ >= self.max_rules:
            finished = True
            break

    # annotate final tree with node_id and value_sklearn, and prepare importance_data_
    importance_data = []
    for tree_ in self.trees_:
        node_counter = iter(range(0, int(1e06)))

        def _annotate_node(node: Node, X, y, weights, is_classmixin=False):
            #TODO: impurity decrease is correct
            if node is None:
                return

            # value_sklearn holds weighted class totals, matching what
            # sklearn stores, so that importances and the converted tree
            # both reflect sample_weight
            #TODO: how to handdle for n_outputs> 1?
            if is_classmixin:
                value_sklearn = np.zeros(self.n_outputs)
                classes = np.argmax(y, axis=1)
                for class_idx in np.unique(classes):
                    value_sklearn[class_idx] = weights[classes == class_idx].sum()
                value_sklearn = value_sklearn.astype(float)

            else:
                value_sklearn = np.array([weights.sum()], dtype=float)

            node.setattrs(node_id=next(node_counter),
                          value_sklearn=value_sklearn,
                          n_samples_=X.shape[0])

            if node.left is None and node.right is None:
                # a leaf splits on nothing: its feature is the -2 placeholder,
                # which indexes the wrong column (or raises, with one feature)
                return

            idxs_left = X[:, node.feature] <= node.threshold
            _annotate_node(node.left, X[idxs_left], y[idxs_left],
                           weights[idxs_left], is_classmixin)
            _annotate_node(node.right, X[~idxs_left], y[~idxs_left],
                           weights[~idxs_left], is_classmixin)

        annotate_weights = (np.ones(X.shape[0]) if sample_weight is None
                            else np.asarray(sample_weight, dtype=float))
        _annotate_node(tree_, X, y, annotate_weights,
                       isinstance(self, ClassifierMixin))

        # now that the samples per node are known, we can start to compute the importances
        importance_data_tree = np.zeros(self.n_features)

        def _importances(node: Node):
            if node is None or node.left is None:
                return 0.0

            # value_sklearn is weighted, so these importances are too
            importance_data_tree[node.feature] += (
                np.sum(node.value_sklearn) * node.impurity
                - np.sum(node.left.value_sklearn) * node.left.impurity
                - np.sum(node.right.value_sklearn) * node.right.impurity
            )

            return (
                np.sum(node.value_sklearn)
                + _importances(node.left)
                + _importances(node.right)
            )

        # require the tree to have more than 1 node, otherwise just leave importance_data_tree as zeros
        if 1 < next(node_counter):
            tree_samples = _importances(tree_)
            if tree_samples != 0:
                importance_data_tree /= tree_samples
            else:
                importance_data_tree = 0

        importance_data.append(importance_data_tree)

    self.importance_data_ = importance_data
    
    return self
def get_rules(self, feature_names=None)

Return this model's rules as a DataFrame (see imodels.get_rules).

Expand source code
def get_rules(self, feature_names=None):
    """Return this model's rules as a DataFrame (see imodels.get_rules)."""
    from imodels.util.get_rules import get_rules
    return get_rules(self, feature_names=feature_names)
def plot(self, cols=2, feature_names=None, filename=None, label='all', impurity=False, tree_number=None, dpi=150, fig_size=None)
Expand source code
def plot(
    self,
    cols=2,
    feature_names=None,
    filename=None,
    label="all",
    impurity=False,
    tree_number=None,
    dpi=150,
    fig_size=None,
):
    is_single_tree = len(self.trees_) < 2 or tree_number is not None

    if feature_names is None:
        if hasattr(self, "feature_names_") and self.feature_names_ is not None:
            feature_names = self.feature_names_

    n_plots = int(len(self.trees_)) if tree_number is None else 1
    # lay the trees out over `cols` columns, rather than stacking them all
    # in a single one
    n_cols = 1 if is_single_tree else max(1, min(int(cols), n_plots))
    n_rows = int(np.ceil(n_plots / n_cols))
    fig, axs = plt.subplots(n_rows, n_cols, dpi=dpi, squeeze=False)
    if fig_size is not None:
        fig.set_size_inches(fig_size, fig_size)

    # any trailing cells of the grid hold no tree
    for ax in axs.flat[n_plots:]:
        ax.axis("off")

    n_classes = 1 if isinstance(self, RegressorMixin) else self.n_outputs
    for i in range(n_plots):
        ax = axs.flat[i]
        try:
            dt = extract_sklearn_tree_from_figs(
                self, i if tree_number is None else tree_number, n_classes
            )
            plot_tree(
                dt,
                ax=ax,
                feature_names=feature_names,
                label=label,
                impurity=impurity,
            )
        except IndexError:
            ax.axis("off")
            continue
        ttl = f"Tree {i}" if n_plots > 1 else f"Tree {tree_number}"
        ax.set_title(ttl)
    if filename is not None:
        plt.savefig(filename)
        return
    plt.show()
def predict(self, X, categorical_features=None, by_tree=False)
Expand source code
def predict(self, X, categorical_features=None, by_tree=False):
    categorical_features = self._categorical_features(categorical_features)
    if hasattr(self, "_encoder"):
        X = self._encode_categories(
            X, categorical_features=categorical_features, encoder_name="_encoder")
    X = check_array(check_predict_X(self, X))
    preds = np.zeros((X.shape[0], self.n_outputs, len(self.trees_)))
    for i, figs_tree in enumerate(self.trees_):
        preds[:, :, i] += self._predict_tree(figs_tree, X)
    
    if isinstance(self, RegressorMixin):
        if by_tree:
            return preds
        else:
            if self.n_outputs==1:
                return np.sum(preds, axis = -1).reshape(-1, )
            return np.sum(preds, axis = -1)
    elif isinstance(self, ClassifierMixin):
        if by_tree:
            return preds
        else:
            preds = np.sum(preds, axis = -1)
            max_indices = np.argmax(preds, axis = 1)

            return np.vectorize(self._class_map.get)(max_indices)

        #TODO: account for non integer classes, FYI self.classes_ comes from check_arguments
def predict_proba(self, X, categorical_features=None, use_clipped_prediction=False)

Predict probability for classifiers: Default behavior is to constrain the outputs to the range of probabilities, i.e. 0 to 1, with a sigmoid function. Set use_clipped_prediction=True to use prior behavior of clipping between 0 and 1 instead.

Expand source code
def predict_proba(self, X, categorical_features=None, use_clipped_prediction=False):
    """Predict probability for classifiers:
    Default behavior is to constrain the outputs to the range of probabilities, i.e. 0 to 1, with a sigmoid function.
    Set use_clipped_prediction=True to use prior behavior of clipping between 0 and 1 instead.
    """
    categorical_features = self._categorical_features(categorical_features)
    if hasattr(self, "_encoder"):
        X = self._encode_categories(
            X, categorical_features=categorical_features, encoder_name="_encoder")
    X = check_array(check_predict_X(self, X))
    if isinstance(self, RegressorMixin):
        return NotImplemented
    preds = np.zeros((X.shape[0], self.n_outputs))
    for figs_tree in self.trees_:
        preds += self._predict_tree(figs_tree, X)
    if use_clipped_prediction:
        # old behavior, pre v1.3.9
        # constrain to range of probabilities by clipping
        return np.clip(preds, a_min=0.0, a_max=1.0)
    else:
        # constrain to range of probabilities with a softmax (multi-class) or a sigmoid (binary) function
        return softmax(preds, axis = 1)
def print_tree(self, X, y, feature_names=None)
Expand source code
def print_tree(self, X, y, feature_names=None):
    s = "------------\n" + "\n\t+\n".join(
        [self._tree_to_str_with_data(X, y, t) for t in self.trees_]
    )
    if feature_names is None:
        if hasattr(self, "feature_names_") and self.feature_names_ is not None:
            feature_names = self.feature_names_
    if feature_names is not None:
        for i in range(len(feature_names))[::-1]:
            s = s.replace(f"X_{i}", feature_names[i])
    return s
def set_fit_request(self: FIGS, *, categorical_features: bool | str | None = '$UNCHANGED$', feature_names: bool | str | None = '$UNCHANGED$', sample_weight: bool | str | None = '$UNCHANGED$', verbose: bool | str | None = '$UNCHANGED$') ‑> FIGS

Configure whether metadata should be requested to be passed to the fit method.

Note that this method is only relevant when this estimator is used as a sub-estimator within a :term:meta-estimator and metadata routing is enabled with enable_metadata_routing=True (see :func:sklearn.set_config). Please check the :ref:User Guide <metadata_routing> on how the routing mechanism works.

The options for each parameter are:

  • True: metadata is requested, and passed to fit if provided. The request is ignored if metadata is not provided.

  • False: metadata is not requested and the meta-estimator will not pass it to fit.

  • None: metadata is not requested, and the meta-estimator will raise an error if the user provides it.

  • str: metadata should be passed to the meta-estimator with this given alias instead of the original name.

The default (sklearn.utils.metadata_routing.UNCHANGED) retains the existing request. This allows you to change the request for some parameters and not others.

Added in version: 1.3

Parameters

categorical_features : str, True, False, or None, default=sklearn.utils.metadata_routing.UNCHANGED
Metadata routing for categorical_features parameter in fit.
feature_names : str, True, False, or None, default=sklearn.utils.metadata_routing.UNCHANGED
Metadata routing for feature_names parameter in fit.
sample_weight : str, True, False, or None, default=sklearn.utils.metadata_routing.UNCHANGED
Metadata routing for sample_weight parameter in fit.
verbose : str, True, False, or None, default=sklearn.utils.metadata_routing.UNCHANGED
Metadata routing for verbose parameter in fit.

Returns

self : object
The updated object.
Expand source code
def func(*args, **kw):
    """Updates the `_metadata_request` attribute of the consumer (`instance`)
    for the parameters provided as `**kw`.

    This docstring is overwritten below.
    See REQUESTER_DOC for expected functionality.
    """
    if not _routing_enabled():
        raise RuntimeError(
            "This method is only available when metadata routing is enabled."
            " You can enable it using"
            " sklearn.set_config(enable_metadata_routing=True)."
        )

    if self.validate_keys and (set(kw) - set(self.keys)):
        raise TypeError(
            f"Unexpected args: {set(kw) - set(self.keys)} in {self.name}. "
            f"Accepted arguments are: {set(self.keys)}"
        )

    # This makes it possible to use the decorated method as an unbound method,
    # for instance when monkeypatching.
    # https://github.com/scikit-learn/scikit-learn/issues/28632
    if instance is None:
        _instance = args[0]
        args = args[1:]
    else:
        _instance = instance

    # Replicating python's behavior when positional args are given other than
    # `self`, and `self` is only allowed if this method is unbound.
    if args:
        raise TypeError(
            f"set_{self.name}_request() takes 0 positional argument but"
            f" {len(args)} were given"
        )

    requests = _instance._get_metadata_request()
    method_metadata_request = getattr(requests, self.name)

    for prop, alias in kw.items():
        if alias is not UNCHANGED:
            method_metadata_request.add_request(param=prop, alias=alias)
    _instance._metadata_request = requests

    return _instance
def set_predict_proba_request(self: FIGS, *, categorical_features: bool | str | None = '$UNCHANGED$', use_clipped_prediction: bool | str | None = '$UNCHANGED$') ‑> FIGS

Configure whether metadata should be requested to be passed to the predict_proba method.

Note that this method is only relevant when this estimator is used as a sub-estimator within a :term:meta-estimator and metadata routing is enabled with enable_metadata_routing=True (see :func:sklearn.set_config). Please check the :ref:User Guide <metadata_routing> on how the routing mechanism works.

The options for each parameter are:

  • True: metadata is requested, and passed to predict_proba if provided. The request is ignored if metadata is not provided.

  • False: metadata is not requested and the meta-estimator will not pass it to predict_proba.

  • None: metadata is not requested, and the meta-estimator will raise an error if the user provides it.

  • str: metadata should be passed to the meta-estimator with this given alias instead of the original name.

The default (sklearn.utils.metadata_routing.UNCHANGED) retains the existing request. This allows you to change the request for some parameters and not others.

Added in version: 1.3

Parameters

categorical_features : str, True, False, or None, default=sklearn.utils.metadata_routing.UNCHANGED
Metadata routing for categorical_features parameter in predict_proba.
use_clipped_prediction : str, True, False, or None, default=sklearn.utils.metadata_routing.UNCHANGED
Metadata routing for use_clipped_prediction parameter in predict_proba.

Returns

self : object
The updated object.
Expand source code
def func(*args, **kw):
    """Updates the `_metadata_request` attribute of the consumer (`instance`)
    for the parameters provided as `**kw`.

    This docstring is overwritten below.
    See REQUESTER_DOC for expected functionality.
    """
    if not _routing_enabled():
        raise RuntimeError(
            "This method is only available when metadata routing is enabled."
            " You can enable it using"
            " sklearn.set_config(enable_metadata_routing=True)."
        )

    if self.validate_keys and (set(kw) - set(self.keys)):
        raise TypeError(
            f"Unexpected args: {set(kw) - set(self.keys)} in {self.name}. "
            f"Accepted arguments are: {set(self.keys)}"
        )

    # This makes it possible to use the decorated method as an unbound method,
    # for instance when monkeypatching.
    # https://github.com/scikit-learn/scikit-learn/issues/28632
    if instance is None:
        _instance = args[0]
        args = args[1:]
    else:
        _instance = instance

    # Replicating python's behavior when positional args are given other than
    # `self`, and `self` is only allowed if this method is unbound.
    if args:
        raise TypeError(
            f"set_{self.name}_request() takes 0 positional argument but"
            f" {len(args)} were given"
        )

    requests = _instance._get_metadata_request()
    method_metadata_request = getattr(requests, self.name)

    for prop, alias in kw.items():
        if alias is not UNCHANGED:
            method_metadata_request.add_request(param=prop, alias=alias)
    _instance._metadata_request = requests

    return _instance
def set_predict_request(self: FIGS, *, by_tree: bool | str | None = '$UNCHANGED$', categorical_features: bool | str | None = '$UNCHANGED$') ‑> FIGS

Configure whether metadata should be requested to be passed to the predict method.

Note that this method is only relevant when this estimator is used as a sub-estimator within a :term:meta-estimator and metadata routing is enabled with enable_metadata_routing=True (see :func:sklearn.set_config). Please check the :ref:User Guide <metadata_routing> on how the routing mechanism works.

The options for each parameter are:

  • True: metadata is requested, and passed to predict if provided. The request is ignored if metadata is not provided.

  • False: metadata is not requested and the meta-estimator will not pass it to predict.

  • None: metadata is not requested, and the meta-estimator will raise an error if the user provides it.

  • str: metadata should be passed to the meta-estimator with this given alias instead of the original name.

The default (sklearn.utils.metadata_routing.UNCHANGED) retains the existing request. This allows you to change the request for some parameters and not others.

Added in version: 1.3

Parameters

by_tree : str, True, False, or None, default=sklearn.utils.metadata_routing.UNCHANGED
Metadata routing for by_tree parameter in predict.
categorical_features : str, True, False, or None, default=sklearn.utils.metadata_routing.UNCHANGED
Metadata routing for categorical_features parameter in predict.

Returns

self : object
The updated object.
Expand source code
def func(*args, **kw):
    """Updates the `_metadata_request` attribute of the consumer (`instance`)
    for the parameters provided as `**kw`.

    This docstring is overwritten below.
    See REQUESTER_DOC for expected functionality.
    """
    if not _routing_enabled():
        raise RuntimeError(
            "This method is only available when metadata routing is enabled."
            " You can enable it using"
            " sklearn.set_config(enable_metadata_routing=True)."
        )

    if self.validate_keys and (set(kw) - set(self.keys)):
        raise TypeError(
            f"Unexpected args: {set(kw) - set(self.keys)} in {self.name}. "
            f"Accepted arguments are: {set(self.keys)}"
        )

    # This makes it possible to use the decorated method as an unbound method,
    # for instance when monkeypatching.
    # https://github.com/scikit-learn/scikit-learn/issues/28632
    if instance is None:
        _instance = args[0]
        args = args[1:]
    else:
        _instance = instance

    # Replicating python's behavior when positional args are given other than
    # `self`, and `self` is only allowed if this method is unbound.
    if args:
        raise TypeError(
            f"set_{self.name}_request() takes 0 positional argument but"
            f" {len(args)} were given"
        )

    requests = _instance._get_metadata_request()
    method_metadata_request = getattr(requests, self.name)

    for prop, alias in kw.items():
        if alias is not UNCHANGED:
            method_metadata_request.add_request(param=prop, alias=alias)
    _instance._metadata_request = requests

    return _instance
class FIGSCV (figs, n_rules_list: List[int] = [6, 12, 24, 30, 50], n_trees_list: List[int] = [5, 10, 15], depth_list: List[int] = [3, 4], min_impurity_decrease_list: List[float] = [0], cv: int = 3, scoring=None, *args, **kwargs)

Base class for all estimators in scikit-learn.

Inheriting from this class provides default implementations of:

  • setting and getting parameters used by GridSearchCV and friends;
  • textual and HTML representation displayed in terminals and IDEs;
  • estimator serialization;
  • parameters validation;
  • data validation;
  • feature names validation.

Read more in the :ref:User Guide <rolling_your_own_estimator>.

Notes

All estimators should specify all the parameters that can be set at the class level in their __init__ as explicit keyword arguments (no *args or **kwargs).

Examples

>>> import numpy as np
>>> from sklearn.base import BaseEstimator
>>> class MyEstimator(BaseEstimator):
...     def __init__(self, *, param=1):
...         self.param = param
...     def fit(self, X, y=None):
...         self.is_fitted_ = True
...         return self
...     def predict(self, X):
...         return np.full(shape=X.shape[0], fill_value=self.param)
>>> estimator = MyEstimator(param=2)
>>> estimator.get_params()
{'param': 2}
>>> X = np.array([[1, 2], [2, 3], [3, 4]])
>>> y = np.array([1, 0, 1])
>>> estimator.fit(X, y).predict(X)
array([2, 2, 2])
>>> estimator.set_params(param=3).fit(X, y).predict(X)
array([3, 3, 3])
Expand source code
class FIGSCV(BaseEstimator):
    def __init__(
        self,
        figs,
        n_rules_list: List[int] = [6, 12, 24, 30, 50],
        n_trees_list: List[int] = [5, 10, 15],
        depth_list: List[int] = [3, 4],
        min_impurity_decrease_list: List[float] = [0],
        cv: int = 3,
        scoring=None,
        *args,
        **kwargs,
    ):

        self._figs_class = figs
        # stored unmodified so that the estimator stays sklearn-cloneable
        self.n_rules_list = n_rules_list
        self.n_trees_list = n_trees_list
        self.depth_list = depth_list
        self.min_impurity_decrease_list = min_impurity_decrease_list
        self.cv = cv
        self.scoring = scoring


    def get_rules(self, feature_names=None):
        """Return this model's rules as a DataFrame (see imodels.get_rules)."""
        from imodels.util.get_rules import get_rules
        return get_rules(self, feature_names=feature_names)

    @property
    def feature_importances_(self):
        """Mean decrease in impurity of the selected FIGS model."""
        return self.figs.feature_importances_
    def apply(self, X):
        """Return the leaf each sample reaches (see imodels.util.apply.apply_leaves)."""
        from imodels.util.apply import apply_leaves
        return apply_leaves(self, X)

    def get_params(self, deep=True):
        # defined explicitly because __init__ takes *args/**kwargs, which sklearn's
        # automatic parameter introspection rejects
        return {
            "n_rules_list": self.n_rules_list,
            "n_trees_list": self.n_trees_list,
            "depth_list": self.depth_list,
            "min_impurity_decrease_list": self.min_impurity_decrease_list,
            "cv": self.cv,
            "scoring": self.scoring,
        }

    def set_params(self, **params):
        for key, value in params.items():
            setattr(self, key, value)
        return self

    def fit(self, X, y):
        self.scores_ = []
        for _i, (n_rules, n_trees, depth, min_impurity_decrease) in enumerate(itertools.product(*[self.n_rules_list, self.n_trees_list, self.depth_list, self.min_impurity_decrease_list])):
            est = self._figs_class(max_rules=n_rules, max_trees=n_trees, max_depth=depth, min_impurity_decrease=min_impurity_decrease)
            cv_scores = cross_val_score(est, X, y, cv=self.cv, scoring=self.scoring)
            mean_score = np.mean(cv_scores)
            if len(self.scores_) == 0:
                self.figs = est
            elif mean_score > np.max(self.scores_):
                self.figs = est

            self.scores_.append(mean_score)
        self.figs.fit(X=X, y=y)
        self.n_features_in_ = self.figs.n_features_in_
        if hasattr(self.figs, "classes_"):
            self.classes_ = self.figs.classes_
        if hasattr(self.figs, "feature_names_in_"):
            self.feature_names_in_ = self.figs.feature_names_in_
        return self

    def predict_proba(self, X):
        check_is_fitted(self, 'figs')
        return self.figs.predict_proba(X)

    def predict(self, X, by_tree = False):
        check_is_fitted(self, 'figs')
        return self.figs.predict(X, by_tree = by_tree)

    @property
    def max_rules(self):
        return self.figs.max_rules

    @property
    def max_trees(self):
        return self.figs.max_trees
    
    @property
    def max_depth(self):
        return self.figs.max_depth
    
    @property
    def min_impurity_decrease(self):
        return self.figs.min_impurity_decrease
    
    @property
    def trees_(self):
        return self.figs.trees_

Ancestors

  • sklearn.base.BaseEstimator
  • sklearn.utils._repr_html.base.ReprHTMLMixin
  • sklearn.utils._repr_html.base._HTMLDocumentationLinkMixin
  • sklearn.utils._metadata_requests._MetadataRequester

Subclasses

Instance variables

var feature_importances_

Mean decrease in impurity of the selected FIGS model.

Expand source code
@property
def feature_importances_(self):
    """Mean decrease in impurity of the selected FIGS model."""
    return self.figs.feature_importances_
var max_depth
Expand source code
@property
def max_depth(self):
    return self.figs.max_depth
var max_rules
Expand source code
@property
def max_rules(self):
    return self.figs.max_rules
var max_trees
Expand source code
@property
def max_trees(self):
    return self.figs.max_trees
var min_impurity_decrease
Expand source code
@property
def min_impurity_decrease(self):
    return self.figs.min_impurity_decrease
var trees_
Expand source code
@property
def trees_(self):
    return self.figs.trees_

Methods

def apply(self, X)

Return the leaf each sample reaches (see imodels.util.apply.apply_leaves).

Expand source code
def apply(self, X):
    """Return the leaf each sample reaches (see imodels.util.apply.apply_leaves)."""
    from imodels.util.apply import apply_leaves
    return apply_leaves(self, X)
def fit(self, X, y)
Expand source code
def fit(self, X, y):
    self.scores_ = []
    for _i, (n_rules, n_trees, depth, min_impurity_decrease) in enumerate(itertools.product(*[self.n_rules_list, self.n_trees_list, self.depth_list, self.min_impurity_decrease_list])):
        est = self._figs_class(max_rules=n_rules, max_trees=n_trees, max_depth=depth, min_impurity_decrease=min_impurity_decrease)
        cv_scores = cross_val_score(est, X, y, cv=self.cv, scoring=self.scoring)
        mean_score = np.mean(cv_scores)
        if len(self.scores_) == 0:
            self.figs = est
        elif mean_score > np.max(self.scores_):
            self.figs = est

        self.scores_.append(mean_score)
    self.figs.fit(X=X, y=y)
    self.n_features_in_ = self.figs.n_features_in_
    if hasattr(self.figs, "classes_"):
        self.classes_ = self.figs.classes_
    if hasattr(self.figs, "feature_names_in_"):
        self.feature_names_in_ = self.figs.feature_names_in_
    return self
def get_params(self, deep=True)

Get parameters for this estimator.

Parameters

deep : bool, default=True
If True, will return the parameters for this estimator and contained subobjects that are estimators.

Returns

params : dict
Parameter names mapped to their values.
Expand source code
def get_params(self, deep=True):
    # defined explicitly because __init__ takes *args/**kwargs, which sklearn's
    # automatic parameter introspection rejects
    return {
        "n_rules_list": self.n_rules_list,
        "n_trees_list": self.n_trees_list,
        "depth_list": self.depth_list,
        "min_impurity_decrease_list": self.min_impurity_decrease_list,
        "cv": self.cv,
        "scoring": self.scoring,
    }
def get_rules(self, feature_names=None)

Return this model's rules as a DataFrame (see imodels.get_rules).

Expand source code
def get_rules(self, feature_names=None):
    """Return this model's rules as a DataFrame (see imodels.get_rules)."""
    from imodels.util.get_rules import get_rules
    return get_rules(self, feature_names=feature_names)
def predict(self, X, by_tree=False)
Expand source code
def predict(self, X, by_tree = False):
    check_is_fitted(self, 'figs')
    return self.figs.predict(X, by_tree = by_tree)
def predict_proba(self, X)
Expand source code
def predict_proba(self, X):
    check_is_fitted(self, 'figs')
    return self.figs.predict_proba(X)
def set_params(self, **params)

Set the parameters of this estimator.

The method works on simple estimators as well as on nested objects (such as :class:~sklearn.pipeline.Pipeline). The latter have parameters of the form <component>__<parameter> so that it's possible to update each component of a nested object.

Parameters

**params : dict
Estimator parameters.

Returns

self : estimator instance
Estimator instance.
Expand source code
def set_params(self, **params):
    for key, value in params.items():
        setattr(self, key, value)
    return self
def set_predict_request(self: FIGSCV, *, by_tree: bool | str | None = '$UNCHANGED$') ‑> FIGSCV

Configure whether metadata should be requested to be passed to the predict method.

Note that this method is only relevant when this estimator is used as a sub-estimator within a :term:meta-estimator and metadata routing is enabled with enable_metadata_routing=True (see :func:sklearn.set_config). Please check the :ref:User Guide <metadata_routing> on how the routing mechanism works.

The options for each parameter are:

  • True: metadata is requested, and passed to predict if provided. The request is ignored if metadata is not provided.

  • False: metadata is not requested and the meta-estimator will not pass it to predict.

  • None: metadata is not requested, and the meta-estimator will raise an error if the user provides it.

  • str: metadata should be passed to the meta-estimator with this given alias instead of the original name.

The default (sklearn.utils.metadata_routing.UNCHANGED) retains the existing request. This allows you to change the request for some parameters and not others.

Added in version: 1.3

Parameters

by_tree : str, True, False, or None, default=sklearn.utils.metadata_routing.UNCHANGED
Metadata routing for by_tree parameter in predict.

Returns

self : object
The updated object.
Expand source code
def func(*args, **kw):
    """Updates the `_metadata_request` attribute of the consumer (`instance`)
    for the parameters provided as `**kw`.

    This docstring is overwritten below.
    See REQUESTER_DOC for expected functionality.
    """
    if not _routing_enabled():
        raise RuntimeError(
            "This method is only available when metadata routing is enabled."
            " You can enable it using"
            " sklearn.set_config(enable_metadata_routing=True)."
        )

    if self.validate_keys and (set(kw) - set(self.keys)):
        raise TypeError(
            f"Unexpected args: {set(kw) - set(self.keys)} in {self.name}. "
            f"Accepted arguments are: {set(self.keys)}"
        )

    # This makes it possible to use the decorated method as an unbound method,
    # for instance when monkeypatching.
    # https://github.com/scikit-learn/scikit-learn/issues/28632
    if instance is None:
        _instance = args[0]
        args = args[1:]
    else:
        _instance = instance

    # Replicating python's behavior when positional args are given other than
    # `self`, and `self` is only allowed if this method is unbound.
    if args:
        raise TypeError(
            f"set_{self.name}_request() takes 0 positional argument but"
            f" {len(args)} were given"
        )

    requests = _instance._get_metadata_request()
    method_metadata_request = getattr(requests, self.name)

    for prop, alias in kw.items():
        if alias is not UNCHANGED:
            method_metadata_request.add_request(param=prop, alias=alias)
    _instance._metadata_request = requests

    return _instance
class FIGSClassifier (max_rules: int = 12, max_trees: int = None, min_impurity_decrease: float = 0.0, random_state=None, max_features: str = None, max_depth: int = None, class_weight=None, verbose: int = 0, n_jobs: int = None)

Mixin class for all classifiers in scikit-learn.

This mixin defines the following functionality:

  • set estimator type to "classifier" through the estimator_type tag;
  • score method that default to :func:~sklearn.metrics.accuracy_score.
  • enforce that fit requires y to be passed through the requires_y tag, which is done by setting the classifier type tag.

Read more in the :ref:User Guide <rolling_your_own_estimator>.

Examples

>>> import numpy as np
>>> from sklearn.base import BaseEstimator, ClassifierMixin
>>> # Mixin classes should always be on the left-hand side for a correct MRO
>>> class MyEstimator(ClassifierMixin, BaseEstimator):
...     def __init__(self, *, param=1):
...         self.param = param
...     def fit(self, X, y=None):
...         self.is_fitted_ = True
...         return self
...     def predict(self, X):
...         return np.full(shape=X.shape[0], fill_value=self.param)
>>> estimator = MyEstimator(param=1)
>>> X = np.array([[1, 2], [2, 3], [3, 4]])
>>> y = np.array([1, 0, 1])
>>> estimator.fit(X, y).predict(X)
array([1, 1, 1])
>>> estimator.score(X, y)
0.66...

Params

max_rules: int Max total number of rules across all trees max_trees: int Max total number of trees min_impurity_decrease: float A node will be split if this split induces a decrease of the impurity greater than or equal to this value. max_features The number of features to consider when looking for the best split (see https://scikit-learn.org/stable/modules/generated/sklearn.ensemble.RandomForestClassifier.html) n_jobs: int, default=None Number of threads used to evaluate candidate splits, which are independent of one another. None means 1; -1 uses all processors. Only helps once there are several candidates to compare, i.e. on larger datasets or deeper models. verbose: int, default=0 Controls progress reporting while fitting. 0 is silent; 1 reports each rule as it is added, with the running total; 2 also prints the model after every rule. Can be overridden per call via fit(verbose=…). class_weight: dict, list of dict or "balanced", default=None Classification only. Weights associated with classes, in the form {class_label: weight}. "balanced" weights each class by n_samples / (n_classes * np.bincount(y)), so that rare classes count as much as common ones. Combined multiplicatively with sample_weight when both are given.

Expand source code
class FIGSClassifier(ClassifierMixin, FIGS):

    @property
    def class_map(self):
        return self._class_map

    def decision_function(self, X):
        """Confidence score for the positive class, one value per sample.

        Defined for binary problems only, matching sklearn's convention; it is
        what scorers like roc_auc and wrappers like BaggingClassifier reach for
        before falling back to predict_proba.
        """
        proba = self.predict_proba(X)
        if proba.shape[1] != 2:
            raise AttributeError(
                "decision_function is only defined for binary classification; "
                f"this model was fitted with {proba.shape[1]} classes. "
                "Use predict_proba instead."
            )
        return proba[:, 1]

Ancestors

  • sklearn.base.ClassifierMixin
  • FIGS
  • sklearn.base.BaseEstimator
  • sklearn.utils._repr_html.base.ReprHTMLMixin
  • sklearn.utils._repr_html.base._HTMLDocumentationLinkMixin
  • sklearn.utils._metadata_requests._MetadataRequester

Instance variables

var class_map
Expand source code
@property
def class_map(self):
    return self._class_map

Methods

def decision_function(self, X)

Confidence score for the positive class, one value per sample.

Defined for binary problems only, matching sklearn's convention; it is what scorers like roc_auc and wrappers like BaggingClassifier reach for before falling back to predict_proba.

Expand source code
def decision_function(self, X):
    """Confidence score for the positive class, one value per sample.

    Defined for binary problems only, matching sklearn's convention; it is
    what scorers like roc_auc and wrappers like BaggingClassifier reach for
    before falling back to predict_proba.
    """
    proba = self.predict_proba(X)
    if proba.shape[1] != 2:
        raise AttributeError(
            "decision_function is only defined for binary classification; "
            f"this model was fitted with {proba.shape[1]} classes. "
            "Use predict_proba instead."
        )
    return proba[:, 1]
def set_score_request(self: FIGSClassifier, *, sample_weight: bool | str | None = '$UNCHANGED$') ‑> FIGSClassifier

Configure whether metadata should be requested to be passed to the score method.

Note that this method is only relevant when this estimator is used as a sub-estimator within a :term:meta-estimator and metadata routing is enabled with enable_metadata_routing=True (see :func:sklearn.set_config). Please check the :ref:User Guide <metadata_routing> on how the routing mechanism works.

The options for each parameter are:

  • True: metadata is requested, and passed to score if provided. The request is ignored if metadata is not provided.

  • False: metadata is not requested and the meta-estimator will not pass it to score.

  • None: metadata is not requested, and the meta-estimator will raise an error if the user provides it.

  • str: metadata should be passed to the meta-estimator with this given alias instead of the original name.

The default (sklearn.utils.metadata_routing.UNCHANGED) retains the existing request. This allows you to change the request for some parameters and not others.

Added in version: 1.3

Parameters

sample_weight : str, True, False, or None, default=sklearn.utils.metadata_routing.UNCHANGED
Metadata routing for sample_weight parameter in score.

Returns

self : object
The updated object.
Expand source code
def func(*args, **kw):
    """Updates the `_metadata_request` attribute of the consumer (`instance`)
    for the parameters provided as `**kw`.

    This docstring is overwritten below.
    See REQUESTER_DOC for expected functionality.
    """
    if not _routing_enabled():
        raise RuntimeError(
            "This method is only available when metadata routing is enabled."
            " You can enable it using"
            " sklearn.set_config(enable_metadata_routing=True)."
        )

    if self.validate_keys and (set(kw) - set(self.keys)):
        raise TypeError(
            f"Unexpected args: {set(kw) - set(self.keys)} in {self.name}. "
            f"Accepted arguments are: {set(self.keys)}"
        )

    # This makes it possible to use the decorated method as an unbound method,
    # for instance when monkeypatching.
    # https://github.com/scikit-learn/scikit-learn/issues/28632
    if instance is None:
        _instance = args[0]
        args = args[1:]
    else:
        _instance = instance

    # Replicating python's behavior when positional args are given other than
    # `self`, and `self` is only allowed if this method is unbound.
    if args:
        raise TypeError(
            f"set_{self.name}_request() takes 0 positional argument but"
            f" {len(args)} were given"
        )

    requests = _instance._get_metadata_request()
    method_metadata_request = getattr(requests, self.name)

    for prop, alias in kw.items():
        if alias is not UNCHANGED:
            method_metadata_request.add_request(param=prop, alias=alias)
    _instance._metadata_request = requests

    return _instance

Inherited members

class FIGSClassifierCV (n_rules_list: List[int] = [6, 12, 24, 30, 50], n_trees_list: List[int] = [5, 10, 15], depth_list: List[int] = [3, 4], min_impurity_decrease_list: List[float] = [0], cv: int = 3, scoring='accuracy', *args, **kwargs)

Mixin class for all classifiers in scikit-learn.

This mixin defines the following functionality:

  • set estimator type to "classifier" through the estimator_type tag;
  • score method that default to :func:~sklearn.metrics.accuracy_score.
  • enforce that fit requires y to be passed through the requires_y tag, which is done by setting the classifier type tag.

Read more in the :ref:User Guide <rolling_your_own_estimator>.

Examples

>>> import numpy as np
>>> from sklearn.base import BaseEstimator, ClassifierMixin
>>> # Mixin classes should always be on the left-hand side for a correct MRO
>>> class MyEstimator(ClassifierMixin, BaseEstimator):
...     def __init__(self, *, param=1):
...         self.param = param
...     def fit(self, X, y=None):
...         self.is_fitted_ = True
...         return self
...     def predict(self, X):
...         return np.full(shape=X.shape[0], fill_value=self.param)
>>> estimator = MyEstimator(param=1)
>>> X = np.array([[1, 2], [2, 3], [3, 4]])
>>> y = np.array([1, 0, 1])
>>> estimator.fit(X, y).predict(X)
array([1, 1, 1])
>>> estimator.score(X, y)
0.66...
Expand source code
class FIGSClassifierCV(ClassifierMixin, FIGSCV):
    def __init__(
        self,
        n_rules_list: List[int] = [6, 12, 24, 30, 50],
        n_trees_list: List[int] = [5, 10, 15],
        depth_list: List[int] = [3, 4],
        min_impurity_decrease_list: List[float] = [0],
        cv: int = 3,
        scoring="accuracy",
        *args,
        **kwargs,
    ):
        super(FIGSClassifierCV, self).__init__(
            figs=FIGSClassifier,
            n_rules_list=n_rules_list,
            n_trees_list=n_trees_list,
            depth_list=depth_list,
            min_impurity_decrease_list=min_impurity_decrease_list,
            cv=cv,
            scoring=scoring,
            *args,
            **kwargs,
        )

Ancestors

  • sklearn.base.ClassifierMixin
  • FIGSCV
  • sklearn.base.BaseEstimator
  • sklearn.utils._repr_html.base.ReprHTMLMixin
  • sklearn.utils._repr_html.base._HTMLDocumentationLinkMixin
  • sklearn.utils._metadata_requests._MetadataRequester

Methods

def set_score_request(self: FIGSClassifierCV, *, sample_weight: bool | str | None = '$UNCHANGED$') ‑> FIGSClassifierCV

Configure whether metadata should be requested to be passed to the score method.

Note that this method is only relevant when this estimator is used as a sub-estimator within a :term:meta-estimator and metadata routing is enabled with enable_metadata_routing=True (see :func:sklearn.set_config). Please check the :ref:User Guide <metadata_routing> on how the routing mechanism works.

The options for each parameter are:

  • True: metadata is requested, and passed to score if provided. The request is ignored if metadata is not provided.

  • False: metadata is not requested and the meta-estimator will not pass it to score.

  • None: metadata is not requested, and the meta-estimator will raise an error if the user provides it.

  • str: metadata should be passed to the meta-estimator with this given alias instead of the original name.

The default (sklearn.utils.metadata_routing.UNCHANGED) retains the existing request. This allows you to change the request for some parameters and not others.

Added in version: 1.3

Parameters

sample_weight : str, True, False, or None, default=sklearn.utils.metadata_routing.UNCHANGED
Metadata routing for sample_weight parameter in score.

Returns

self : object
The updated object.
Expand source code
def func(*args, **kw):
    """Updates the `_metadata_request` attribute of the consumer (`instance`)
    for the parameters provided as `**kw`.

    This docstring is overwritten below.
    See REQUESTER_DOC for expected functionality.
    """
    if not _routing_enabled():
        raise RuntimeError(
            "This method is only available when metadata routing is enabled."
            " You can enable it using"
            " sklearn.set_config(enable_metadata_routing=True)."
        )

    if self.validate_keys and (set(kw) - set(self.keys)):
        raise TypeError(
            f"Unexpected args: {set(kw) - set(self.keys)} in {self.name}. "
            f"Accepted arguments are: {set(self.keys)}"
        )

    # This makes it possible to use the decorated method as an unbound method,
    # for instance when monkeypatching.
    # https://github.com/scikit-learn/scikit-learn/issues/28632
    if instance is None:
        _instance = args[0]
        args = args[1:]
    else:
        _instance = instance

    # Replicating python's behavior when positional args are given other than
    # `self`, and `self` is only allowed if this method is unbound.
    if args:
        raise TypeError(
            f"set_{self.name}_request() takes 0 positional argument but"
            f" {len(args)} were given"
        )

    requests = _instance._get_metadata_request()
    method_metadata_request = getattr(requests, self.name)

    for prop, alias in kw.items():
        if alias is not UNCHANGED:
            method_metadata_request.add_request(param=prop, alias=alias)
    _instance._metadata_request = requests

    return _instance

Inherited members

class FIGSRegressor (max_rules: int = 12, max_trees: int = None, min_impurity_decrease: float = 0.0, random_state=None, max_features: str = None, max_depth: int = None, class_weight=None, verbose: int = 0, n_jobs: int = None)

Mixin class for all regression estimators in scikit-learn.

This mixin defines the following functionality:

  • set estimator type to "regressor" through the estimator_type tag;
  • score method that default to :func:~sklearn.metrics.r2_score.
  • enforce that fit requires y to be passed through the requires_y tag, which is done by setting the regressor type tag.

Read more in the :ref:User Guide <rolling_your_own_estimator>.

Examples

>>> import numpy as np
>>> from sklearn.base import BaseEstimator, RegressorMixin
>>> # Mixin classes should always be on the left-hand side for a correct MRO
>>> class MyEstimator(RegressorMixin, BaseEstimator):
...     def __init__(self, *, param=1):
...         self.param = param
...     def fit(self, X, y=None):
...         self.is_fitted_ = True
...         return self
...     def predict(self, X):
...         return np.full(shape=X.shape[0], fill_value=self.param)
>>> estimator = MyEstimator(param=0)
>>> X = np.array([[1, 2], [2, 3], [3, 4]])
>>> y = np.array([-1, 0, 1])
>>> estimator.fit(X, y).predict(X)
array([0, 0, 0])
>>> estimator.score(X, y)
0.0

Params

max_rules: int Max total number of rules across all trees max_trees: int Max total number of trees min_impurity_decrease: float A node will be split if this split induces a decrease of the impurity greater than or equal to this value. max_features The number of features to consider when looking for the best split (see https://scikit-learn.org/stable/modules/generated/sklearn.ensemble.RandomForestClassifier.html) n_jobs: int, default=None Number of threads used to evaluate candidate splits, which are independent of one another. None means 1; -1 uses all processors. Only helps once there are several candidates to compare, i.e. on larger datasets or deeper models. verbose: int, default=0 Controls progress reporting while fitting. 0 is silent; 1 reports each rule as it is added, with the running total; 2 also prints the model after every rule. Can be overridden per call via fit(verbose=…). class_weight: dict, list of dict or "balanced", default=None Classification only. Weights associated with classes, in the form {class_label: weight}. "balanced" weights each class by n_samples / (n_classes * np.bincount(y)), so that rare classes count as much as common ones. Combined multiplicatively with sample_weight when both are given.

Expand source code
class FIGSRegressor(RegressorMixin, FIGS):
    ...

Ancestors

  • sklearn.base.RegressorMixin
  • FIGS
  • sklearn.base.BaseEstimator
  • sklearn.utils._repr_html.base.ReprHTMLMixin
  • sklearn.utils._repr_html.base._HTMLDocumentationLinkMixin
  • sklearn.utils._metadata_requests._MetadataRequester

Methods

def set_score_request(self: FIGSRegressor, *, sample_weight: bool | str | None = '$UNCHANGED$') ‑> FIGSRegressor

Configure whether metadata should be requested to be passed to the score method.

Note that this method is only relevant when this estimator is used as a sub-estimator within a :term:meta-estimator and metadata routing is enabled with enable_metadata_routing=True (see :func:sklearn.set_config). Please check the :ref:User Guide <metadata_routing> on how the routing mechanism works.

The options for each parameter are:

  • True: metadata is requested, and passed to score if provided. The request is ignored if metadata is not provided.

  • False: metadata is not requested and the meta-estimator will not pass it to score.

  • None: metadata is not requested, and the meta-estimator will raise an error if the user provides it.

  • str: metadata should be passed to the meta-estimator with this given alias instead of the original name.

The default (sklearn.utils.metadata_routing.UNCHANGED) retains the existing request. This allows you to change the request for some parameters and not others.

Added in version: 1.3

Parameters

sample_weight : str, True, False, or None, default=sklearn.utils.metadata_routing.UNCHANGED
Metadata routing for sample_weight parameter in score.

Returns

self : object
The updated object.
Expand source code
def func(*args, **kw):
    """Updates the `_metadata_request` attribute of the consumer (`instance`)
    for the parameters provided as `**kw`.

    This docstring is overwritten below.
    See REQUESTER_DOC for expected functionality.
    """
    if not _routing_enabled():
        raise RuntimeError(
            "This method is only available when metadata routing is enabled."
            " You can enable it using"
            " sklearn.set_config(enable_metadata_routing=True)."
        )

    if self.validate_keys and (set(kw) - set(self.keys)):
        raise TypeError(
            f"Unexpected args: {set(kw) - set(self.keys)} in {self.name}. "
            f"Accepted arguments are: {set(self.keys)}"
        )

    # This makes it possible to use the decorated method as an unbound method,
    # for instance when monkeypatching.
    # https://github.com/scikit-learn/scikit-learn/issues/28632
    if instance is None:
        _instance = args[0]
        args = args[1:]
    else:
        _instance = instance

    # Replicating python's behavior when positional args are given other than
    # `self`, and `self` is only allowed if this method is unbound.
    if args:
        raise TypeError(
            f"set_{self.name}_request() takes 0 positional argument but"
            f" {len(args)} were given"
        )

    requests = _instance._get_metadata_request()
    method_metadata_request = getattr(requests, self.name)

    for prop, alias in kw.items():
        if alias is not UNCHANGED:
            method_metadata_request.add_request(param=prop, alias=alias)
    _instance._metadata_request = requests

    return _instance

Inherited members

class FIGSRegressorCV (n_rules_list: List[int] = [6, 12, 24, 30, 50], n_trees_list: List[int] = [5, 10, 15], depth_list: List[int] = [3, 4], min_impurity_decrease_list: List[float] = [0], cv: int = 3, scoring='r2', *args, **kwargs)

Mixin class for all regression estimators in scikit-learn.

This mixin defines the following functionality:

  • set estimator type to "regressor" through the estimator_type tag;
  • score method that default to :func:~sklearn.metrics.r2_score.
  • enforce that fit requires y to be passed through the requires_y tag, which is done by setting the regressor type tag.

Read more in the :ref:User Guide <rolling_your_own_estimator>.

Examples

>>> import numpy as np
>>> from sklearn.base import BaseEstimator, RegressorMixin
>>> # Mixin classes should always be on the left-hand side for a correct MRO
>>> class MyEstimator(RegressorMixin, BaseEstimator):
...     def __init__(self, *, param=1):
...         self.param = param
...     def fit(self, X, y=None):
...         self.is_fitted_ = True
...         return self
...     def predict(self, X):
...         return np.full(shape=X.shape[0], fill_value=self.param)
>>> estimator = MyEstimator(param=0)
>>> X = np.array([[1, 2], [2, 3], [3, 4]])
>>> y = np.array([-1, 0, 1])
>>> estimator.fit(X, y).predict(X)
array([0, 0, 0])
>>> estimator.score(X, y)
0.0
Expand source code
class FIGSRegressorCV(RegressorMixin, FIGSCV):
    def __init__(
        self,
        n_rules_list: List[int] = [6, 12, 24, 30, 50],
        n_trees_list: List[int] = [5, 10, 15],
        depth_list: List[int] = [3, 4],
        min_impurity_decrease_list: List[float] = [0],
        cv: int = 3,
        scoring="r2",
        *args,
        **kwargs,
    ):
        super(FIGSRegressorCV, self).__init__(
            figs=FIGSRegressor,
            n_rules_list=n_rules_list,
            n_trees_list=n_trees_list,
            depth_list=depth_list,
            min_impurity_decrease_list=min_impurity_decrease_list,
            cv=cv,
            scoring=scoring,
            *args,
            **kwargs,
        )

Ancestors

  • sklearn.base.RegressorMixin
  • FIGSCV
  • sklearn.base.BaseEstimator
  • sklearn.utils._repr_html.base.ReprHTMLMixin
  • sklearn.utils._repr_html.base._HTMLDocumentationLinkMixin
  • sklearn.utils._metadata_requests._MetadataRequester

Methods

def set_score_request(self: FIGSRegressorCV, *, sample_weight: bool | str | None = '$UNCHANGED$') ‑> FIGSRegressorCV

Configure whether metadata should be requested to be passed to the score method.

Note that this method is only relevant when this estimator is used as a sub-estimator within a :term:meta-estimator and metadata routing is enabled with enable_metadata_routing=True (see :func:sklearn.set_config). Please check the :ref:User Guide <metadata_routing> on how the routing mechanism works.

The options for each parameter are:

  • True: metadata is requested, and passed to score if provided. The request is ignored if metadata is not provided.

  • False: metadata is not requested and the meta-estimator will not pass it to score.

  • None: metadata is not requested, and the meta-estimator will raise an error if the user provides it.

  • str: metadata should be passed to the meta-estimator with this given alias instead of the original name.

The default (sklearn.utils.metadata_routing.UNCHANGED) retains the existing request. This allows you to change the request for some parameters and not others.

Added in version: 1.3

Parameters

sample_weight : str, True, False, or None, default=sklearn.utils.metadata_routing.UNCHANGED
Metadata routing for sample_weight parameter in score.

Returns

self : object
The updated object.
Expand source code
def func(*args, **kw):
    """Updates the `_metadata_request` attribute of the consumer (`instance`)
    for the parameters provided as `**kw`.

    This docstring is overwritten below.
    See REQUESTER_DOC for expected functionality.
    """
    if not _routing_enabled():
        raise RuntimeError(
            "This method is only available when metadata routing is enabled."
            " You can enable it using"
            " sklearn.set_config(enable_metadata_routing=True)."
        )

    if self.validate_keys and (set(kw) - set(self.keys)):
        raise TypeError(
            f"Unexpected args: {set(kw) - set(self.keys)} in {self.name}. "
            f"Accepted arguments are: {set(self.keys)}"
        )

    # This makes it possible to use the decorated method as an unbound method,
    # for instance when monkeypatching.
    # https://github.com/scikit-learn/scikit-learn/issues/28632
    if instance is None:
        _instance = args[0]
        args = args[1:]
    else:
        _instance = instance

    # Replicating python's behavior when positional args are given other than
    # `self`, and `self` is only allowed if this method is unbound.
    if args:
        raise TypeError(
            f"set_{self.name}_request() takes 0 positional argument but"
            f" {len(args)} were given"
        )

    requests = _instance._get_metadata_request()
    method_metadata_request = getattr(requests, self.name)

    for prop, alias in kw.items():
        if alias is not UNCHANGED:
            method_metadata_request.add_request(param=prop, alias=alias)
    _instance._metadata_request = requests

    return _instance

Inherited members

class Node (feature: int = None, threshold: int = None, value=None, value_sklearn=None, idxs=None, is_root: bool = False, left=None, impurity: float = None, impurity_reduction: float = None, tree_num: int = None, node_id: int = None, right=None, depth=None)

Node class for splitting

Expand source code
class Node:
    def __init__(
        self,
        feature: int = None,
        threshold: int = None,
        value=None,
        value_sklearn=None,
        idxs=None,
        is_root: bool = False,
        left=None,
        impurity: float = None,
        impurity_reduction: float = None,
        tree_num: int = None,
        node_id: int = None,
        right=None,
        depth=None,
    ):
        """Node class for splitting"""

        # split or linear
        self.is_root = is_root
        self.idxs = idxs
        self.tree_num = tree_num
        self.node_id = None
        self.feature = feature
        self.impurity = impurity
        self.impurity_reduction = impurity_reduction
        self.value_sklearn = value_sklearn

        # different meanings
        self.value = value # for split this is mean, for linear thifs is weight
        if isinstance(self.value, np.ndarray):
            self.value = self.value.reshape(-1, )

        # split-specific
        self.threshold = threshold
        self.left = left
        self.right = right
        self.left_temp = None
        self.right_temp = None
        #root node has depth 0
        self.depth = depth

    def setattrs(self, **kwargs):
        for k, v in kwargs.items():
            setattr(self, k, v)

    def __str__(self):
        if self.is_root:
            return f"X_{self.feature} <= {self.threshold:0.3f} (Tree #{self.tree_num} root)"
        elif self.left is None and self.right is None:
            return f"Val: {' '.join([str(np.round(i, 3)) for i in self.value])} (leaf)"
        else:
            return f"X_{self.feature} <= {self.threshold:0.3f} (split)"

    def print_root(self, y, is_classmixin, n_outputs):
        if is_classmixin:
            unique, counts = np.unique(y, return_counts=True)
            class_counts = np.zeros(n_outputs, dtype=int)
            class_counts[unique] = counts
        else:
            class_counts = np.zeros(n_outputs, dtype=int) 

        class_counts_str = ", ".join(map(str, class_counts))
        proportions_str = ", ".join(f"{p:.2f}" for p in np.round(100 * class_counts / y.shape[0], 2))

        one_proportion = f" [{class_counts_str}]/{y.shape[0]} ({proportions_str}%)"
        if self.is_root:
            return f"X_{self.feature} <= {self.threshold:0.3f}" + one_proportion
        elif self.left is None and self.right is None:
            return "ΔRisk = [" + ", ".join(f"{v:.2f}" for v in self.value) + "]" + one_proportion
        else:
            return f"X_{self.feature} <= {self.threshold:0.3f}" + one_proportion


    def __repr__(self):
        return self.__str__()

Methods

def print_root(self, y, is_classmixin, n_outputs)
Expand source code
def print_root(self, y, is_classmixin, n_outputs):
    if is_classmixin:
        unique, counts = np.unique(y, return_counts=True)
        class_counts = np.zeros(n_outputs, dtype=int)
        class_counts[unique] = counts
    else:
        class_counts = np.zeros(n_outputs, dtype=int) 

    class_counts_str = ", ".join(map(str, class_counts))
    proportions_str = ", ".join(f"{p:.2f}" for p in np.round(100 * class_counts / y.shape[0], 2))

    one_proportion = f" [{class_counts_str}]/{y.shape[0]} ({proportions_str}%)"
    if self.is_root:
        return f"X_{self.feature} <= {self.threshold:0.3f}" + one_proportion
    elif self.left is None and self.right is None:
        return "ΔRisk = [" + ", ".join(f"{v:.2f}" for v in self.value) + "]" + one_proportion
    else:
        return f"X_{self.feature} <= {self.threshold:0.3f}" + one_proportion
def setattrs(self, **kwargs)
Expand source code
def setattrs(self, **kwargs):
    for k, v in kwargs.items():
        setattr(self, k, v)