Source code for RuleTree.base.RuleTreeBaseGainSplitter

"""
Base class of the splitters defined by a gain function: the simplest way to write a new splitter.
"""
from abc import ABC, abstractmethod

import numpy as np

from RuleTree.base.RuleTreeBaseSplitter import RuleTreeBaseSplitter
from RuleTree.exceptions import NoSplitFoundWarning


[docs] class RuleTreeBaseGainSplitter(RuleTreeBaseSplitter, ABC): """ Base class of the splitters that test every candidate split and keep the one with the highest gain: a new splitter only defines the task (``task``) and the gain of a split (:meth:`gain`). The candidate splits are the thresholds halfway between consecutive distinct values of each numerical feature (``x <= threshold`` goes left) and the values of each categorical feature (``x == value`` goes left). Splits with fewer than ``min_samples_leaf`` instances on a side are skipped. The search is exhaustive and not optimized: ``gain`` is called once per candidate. Attributes ---------- min_samples_leaf : int Minimum number of instances on each side of a split. 1 unless the subclass sets it (e.g. as a parameter of ``__init__``). See Also -------- RuleTreeBaseSplitter : the attributes set by fit. Examples -------- >>> import numpy as np >>> from RuleTree.base.RuleTreeBaseGainSplitter import RuleTreeBaseGainSplitter >>> from RuleTree.utils.define import MODEL_TYPE_CLF >>> class MisclassificationSplitter(RuleTreeBaseGainSplitter): ... task = MODEL_TYPE_CLF ... ... def gain(self, y, left, sample_weight=None): ... # decrease of the number of errors when each side predicts its majority class ... errors = lambda labels: len(labels) - np.unique(labels, return_counts=True)[1].max() ... return errors(y) - errors(y[left]) - errors(y[~left]) >>> X = np.array([[1.0], [2.0], [3.0], [4.0]]) >>> splitter = MisclassificationSplitter().fit(X, np.array([0, 0, 1, 1]), numerical=[0], categorical=[]) >>> splitter.feature_, splitter.threshold_ (0, 2.5) """ min_samples_leaf = 1
[docs] @abstractmethod def gain(self, y: np.ndarray, left: np.ndarray, sample_weight=None) -> float: """ Gain of a candidate split: the higher, the better. Parameters ---------- y : numpy.ndarray of shape (n_samples,) Target of the instances in the node. left : numpy.ndarray of bool of shape (n_samples,) Instances that go to the left child. sample_weight : numpy.ndarray of shape (n_samples,), default=None Sample weights of the instances in the node. Returns ------- gain : float """
[docs] def candidate_thresholds(self, x: np.ndarray) -> np.ndarray: """ Thresholds tested on a numerical feature: halfway between its consecutive distinct values. Parameters ---------- x : numpy.ndarray of shape (n_samples,) Values of the feature. Returns ------- thresholds : numpy.ndarray """ values = np.unique(x.astype(float)) return (values[:-1] + values[1:]) / 2
[docs] def fit(self, X, y, numerical, categorical, sample_weight=None, check_input=True): """ Test every candidate split and keep the one with the highest gain. Parameters ---------- X : numpy.ndarray of shape (n_samples, n_features) Feature matrix of the instances in the node. y : numpy.ndarray of shape (n_samples,) Target of the instances in the node. numerical : list of int Indices of the numerical columns of X. categorical : list of int Indices of the categorical columns of X. sample_weight : numpy.ndarray of shape (n_samples,), default=None Sample weights of the instances in the node, passed to gain. check_input : bool, default=True Not used. Returns ------- self : RuleTreeBaseGainSplitter The fitted splitter. Raises ------ NoSplitFoundWarning If no candidate split has at least min_samples_leaf instances on each side. """ self.feature_, self.threshold_, self.is_categorical_, self.impurity_ = None, None, False, None self.gain_ = -np.inf candidates = [(feature, float(threshold), False) for feature in numerical for threshold in self.candidate_thresholds(X[:, feature])] candidates += [(feature, value, True) for feature in categorical for value in np.unique(X[:, feature])] for feature, threshold, is_categorical in candidates: left = X[:, feature] == threshold if is_categorical else X[:, feature].astype(float) <= threshold if min(left.sum(), (~left).sum()) < self.min_samples_leaf: continue gain = self.gain(y, left, sample_weight) if gain > self.gain_: self.feature_, self.threshold_, self.is_categorical_, self.gain_ = feature, threshold, is_categorical, gain if self.feature_ is None: raise NoSplitFoundWarning(f"No split with at least {self.min_samples_leaf} instances per side") return self