"""Abstract base class of the stumps: the single splits that make up the nodes of a RuleTree."""
import hashlib
import json
from abc import ABC, abstractmethod
from typing import Optional
import numpy as np
from sklearn.base import BaseEstimator, TransformerMixin
from RuleTree.utils.define import DATA_TYPE_TABULAR
[docs]
class RuleTreeBaseStump(BaseEstimator, ABC):
"""
Abstract base class of the stumps, the splits of the nodes of a RuleTree.
A stump divides the instances of a node into two children: ``apply`` returns 1 for the
instances that go to the left child and 2 for the ones that go to the right child.
Notes
-----
Stumps receive the whole data set as a dictionary ``data = {data_type: array}`` (see
:data:`RuleTree.utils.define.DATA_INPUT_TYPES`) together with ``idx``, the indices of the
instances reaching the node. A fitted stump works on a single data type, chosen by the tree
among ``supported_data_types`` and stored in ``data_type_``. For convenience, ``data`` can
also be a single array, interpreted as data of the stump's data type.
The tree passed as ``context`` holds a cache that lives until the next ``fit`` (or
``clear_cache()``), where stumps can store anything that can be shared among splits, e.g.
embeddings computed on the whole data set. The stumps of the same class with the same
hyperparameters share a cache, named by :meth:`cache_namespace`. Everything needed by ``apply``
must be stored in the stump itself.
Subclasses that work on a 2D matrix can implement ``_transform`` (data of the stump's data
type -> matrix) and ``_apply_matrix`` (matrix -> 1/2 labels) and reuse :meth:`apply` and
:meth:`update_statistics`. The stumps of the library are made of a transformer and a splitter
(see :class:`RuleTree.stumps.Stump.Stump`), which is the simplest way to write a new stump.
Attributes
----------
supported_data_types : tuple of str
Data types the stump can be fitted on.
task : str
Kind of target, MODEL_TYPE_CLF (classification) or MODEL_TYPE_REG (regression).
data_type_ : str
Data type the stump has been fitted on.
See Also
--------
RuleTree.stumps.Stump.Stump : a stump made of a transformer and a splitter, the simplest way to
write a new stump (see :doc:`/guides/extending`).
"""
supported_data_types = (DATA_TYPE_TABULAR,)
[docs]
@abstractmethod
def fit(self, data, y, idx=None, context=None, data_type=None, sample_weight=None, check_input=True):
"""
Fit the stump on the instances ``idx`` of ``data``.
Parameters
----------
data : dict or numpy.ndarray
Whole data set, as ``{data_type: array}``.
y : numpy.ndarray
Target of the whole data set.
idx : numpy.ndarray, default=None
Indices of the instances reaching the node. If None, all the instances.
context : RuleTree, default=None
Tree being built, used to share cached values among the splits.
data_type : str, default=None
Data type to use. If None, the first supported data type available in ``data``.
sample_weight : numpy.ndarray, default=None
Sample weights of the whole data set.
check_input : bool, default=True
Whether to check the input data.
Returns
-------
self : RuleTreeBaseStump
The fitted stump.
Raises
------
NoSplitFoundWarning
If the instances cannot be split.
"""
raise NotImplementedError("The fit method must be implemented in subclasses.")
[docs]
@abstractmethod
def get_rule(self, columns_names:list=None, scaler:TransformerMixin=None, float_precision: Optional[int] = 3) -> dict:
"""
Human-readable representation of the split.
Parameters
----------
columns_names : list of str, default=None
Names of the features, used instead of their indices.
scaler : TransformerMixin, default=None
Scaler used to preprocess the data: the thresholds are shown in the original scale.
float_precision : int, default=3
Number of decimals of the thresholds. If None, they are not rounded.
Returns
-------
rule : dict
The rule, with at least the keys ``textual_rule`` and ``not_textual_rule`` (the condition
of the left child and its negation), ``blob_rule``, ``not_blob_rule``, ``graphviz_rule`` and
``not_graphviz_rule`` (attributes of the Graphviz node).
"""
pass
[docs]
def get_graphviz_rule(self, columns_names: list = None, scaler: TransformerMixin = None,
float_precision: Optional[int] = 3) -> dict:
"""
Attributes of the Graphviz node of the split (see RuleTree.export_graphviz). The default
implementation is ``get_rule(...)["graphviz_rule"]``; stumps that draw something in the node,
such as the shapelet stumps, override it.
Parameters
----------
columns_names : list of str, default=None
Names of the tabular features.
scaler : TransformerMixin, default=None
Scaler used to preprocess the data: the thresholds are shown in the original scale.
float_precision : int, default=3
Number of decimals of the threshold.
Returns
-------
attributes : dict
Attributes of the Graphviz node, e.g. ``label``.
"""
return self.get_rule(columns_names=columns_names, scaler=scaler,
float_precision=float_precision)["graphviz_rule"]
[docs]
@abstractmethod
def node_to_dict(self) -> dict:
"""
Dictionary representation of the stump, used to save the tree (see RuleTree.to_dict).
Returns
-------
node_dict : dict
JSON-friendly dictionary with the key ``stump_type`` (module of the stump class) and all
that is needed by :meth:`dict_to_node` to rebuild the stump.
"""
raise NotImplementedError("The node_to_dict method must be implemented in subclasses.")
[docs]
@classmethod
@abstractmethod
def dict_to_node(cls, node_dict: dict) -> 'RuleTreeBaseStump':
"""
Create a stump from a dictionary created by :meth:`node_to_dict`.
Parameters
----------
node_dict : dict
Dictionary representation of the stump.
Returns
-------
stump : RuleTreeBaseStump
The stump, fitted if it was fitted when saved.
"""
raise NotImplementedError("The dict_to_node method must be implemented in subclasses.")
@property
def task(self) -> str:
"""
Kind of target of the stump: MODEL_TYPE_CLF (classification) or MODEL_TYPE_REG (regression).
A tree accepts only stumps of its task (see RuleTree._get_stumps_task).
"""
raise NotImplementedError(f"{self.__class__.__name__} must define its task.")
[docs]
def supports(self, data_type:str) -> bool:
"""
Whether the stump can be fitted on a data type.
Parameters
----------
data_type : str
A data type, e.g. ``"tabular"`` (see :data:`RuleTree.utils.define.DATA_INPUT_TYPES`).
Returns
-------
supported : bool
True if ``data_type`` is in ``supported_data_types``.
"""
return data_type in self.supported_data_types
[docs]
def apply(self, data, idx=None) -> np.ndarray:
"""
Apply the split to the instances ``idx`` of ``data``.
Parameters
----------
data : dict or numpy.ndarray
Data set, as ``{data_type: array}`` or a single array of the stump's data type.
idx : numpy.ndarray, default=None
Indices of the instances to split. If None, all the instances.
Returns
-------
labels : numpy.ndarray of int
For each instance in ``idx``, 1 if it goes to the left child, 2 otherwise.
"""
X = self._get_data(data)
if idx is not None:
X = X[idx]
return self._apply_matrix(self._transform(X))
[docs]
def update_statistics(self, data, y, idx=None, context=None, sample_weight=None, check_input=True):
"""
Recompute the statistics of the split (e.g. impurity) on the instances ``idx`` of ``data``,
without changing the split.
Parameters
----------
data : dict or numpy.ndarray
Whole data set, as ``{data_type: array}``.
y : numpy.ndarray
Target of the whole data set.
idx : numpy.ndarray, default=None
Indices of the instances reaching the node. If None, all the instances.
context : RuleTree, default=None
Not used, kept for symmetry with :meth:`fit`.
sample_weight : numpy.ndarray, default=None
Sample weights of the whole data set.
check_input : bool, default=True
Not used, kept for symmetry with :meth:`fit`.
"""
if idx is None:
idx = slice(None)
X = self._transform(self._get_data(data)[idx])
self._update_statistics_matrix(X, y[idx], None if sample_weight is None else sample_weight[idx])
def _transform(self, X: np.ndarray) -> np.ndarray:
"""
Turn data of the stump's data type into the matrix the split is applied on.
Parameters
----------
X : numpy.ndarray
Data of the stump's data type.
Returns
-------
X_transformed : numpy.ndarray of shape (n_samples, n_features)
Feature matrix. The default implementation returns X.
"""
return X
def _apply_matrix(self, X: np.ndarray) -> np.ndarray:
"""
Apply the split to the matrix returned by :meth:`_transform`.
Parameters
----------
X : numpy.ndarray of shape (n_samples, n_features)
Feature matrix.
Returns
-------
labels : numpy.ndarray of int
1 for the rows that go to the left child, 2 otherwise.
"""
raise NotImplementedError(f"{self.__class__.__name__} must implement apply or _apply_matrix.")
def _update_statistics_matrix(self, X: np.ndarray, y: np.ndarray, sample_weight=None):
"""
Recompute the split statistics on the matrix returned by :meth:`_transform`.
Parameters
----------
X : numpy.ndarray of shape (n_samples, n_features)
Feature matrix of the instances in the node.
y : numpy.ndarray
Target of the instances in the node.
sample_weight : numpy.ndarray, default=None
Sample weights of the instances in the node.
"""
def _resolve_fit_data(self, data, data_type=None) -> tuple[np.ndarray, str]:
"""
Select the data to fit on and store its type in ``data_type_``.
Parameters
----------
data : dict or numpy.ndarray
Data set, as ``{data_type: array}`` or a single array.
data_type : str, default=None
Data type to use. If None, the first supported data type available in ``data``.
Returns
-------
X : numpy.ndarray
Data of the selected type (all the instances).
data_type : str
The selected data type.
Raises
------
ValueError
If the data type is not supported or not available in ``data``.
"""
if not isinstance(data, dict):
data = {data_type if data_type is not None else self.supported_data_types[0]: data}
if data_type is None:
available = [t for t in self.supported_data_types if t in data]
if not available:
raise ValueError(f"{self.__class__.__name__} supports {list(self.supported_data_types)} data, "
f"got {list(data)}.")
data_type = available[0]
if not self.supports(data_type):
raise ValueError(f"{self.__class__.__name__} does not support {data_type} data.")
if data_type not in data:
raise ValueError(f"No {data_type} data provided.")
self.data_type_ = data_type
return np.asarray(data[data_type]), data_type
def _get_data(self, data) -> np.ndarray:
"""
Data of the type the stump has been fitted on.
Parameters
----------
data : dict or numpy.ndarray
Data set, as ``{data_type: array}`` or a single array of the stump's data type.
Returns
-------
X : numpy.ndarray
Data of the type ``data_type_``.
"""
return np.asarray(data[self.data_type_]) if isinstance(data, dict) else np.asarray(data)
[docs]
def cache_namespace(self) -> str:
"""
Name of the cache of the stump in the tree (see RuleTree.get_cache): the class name and a hash
of the hyperparameters, also of the components (e.g. the transformer of a Stump). Stumps of
the same class with different hyperparameters do not share cached values.
The tree gives its ``random_state`` to the stumps without one, so the namespace of a stump
used by a tree is the one of its copy in ``candidate_splits_``.
Returns
-------
namespace : str
E.g. ``"PivotTreeStumpClassifier-1a2b3c4d"``.
Examples
--------
>>> from RuleTree.stumps.classification import DecisionTreeStumpClassifier
>>> a, b = DecisionTreeStumpClassifier(), DecisionTreeStumpClassifier(criterion="entropy")
>>> a.cache_namespace().startswith("DecisionTreeStumpClassifier-")
True
>>> a.cache_namespace() == DecisionTreeStumpClassifier().cache_namespace() != b.cache_namespace()
True
"""
params = {name: f"<{type(value).__module__}.{type(value).__qualname__}>"
if isinstance(value, BaseEstimator) else value
for name, value in self.get_params(deep=True).items()}
digest = hashlib.md5(json.dumps(params, sort_keys=True, default=repr).encode()).hexdigest()
return f"{self.__class__.__name__}-{digest[:8]}"
def _get_cache(self, context) -> dict:
"""
Cache of the stump in the tree ``context``, named by :meth:`cache_namespace`.
Parameters
----------
context : RuleTree or None
The tree being built.
Returns
-------
cache : dict
The cache of the stump; without a context, an empty dictionary that is not kept.
"""
return context.get_cache(self.cache_namespace()) if context is not None else {}
def _is_number(value) -> bool:
"""
Whether a value is a number (bool excluded), e.g. a numerical threshold.
Parameters
----------
value : object
Any value.
Returns
-------
is_number : bool
"""
return isinstance(value, (int, float, np.number)) and not isinstance(value, bool)