Source code for snapboost.snapboost

from numbers import Integral, Real
from collections.abc import Sequence
import inspect

import numpy as np

from sklearn.tree import DecisionTreeRegressor
from sklearn.kernel_ridge import KernelRidge
from hnbm import HNBM, HNBMClassifier, HNBMRegressor
from .rff_learner import RandomFourierRidgeRegressor
from .linear_learner import WeightedLinearRegressor


class _SnapBoostMixin:
    """Shared SnapBoost learner pool configuration."""

    _REBUILD_PARAMS = frozenset({
        "p_tree", "p_linear", "alpha", "gamma", "n_components",
        "min_max_depth", "max_max_depth", "min_samples_leaf", "random_state",
        "max_features", "scale_features",
        "kernel_gammas", "kernel_types",
        "monotonic_cst",
    })

    def _init_snapboost_params(
        self,
        p_tree=0.9,
        p_linear=0.0,
        min_max_depth=2,
        max_max_depth=4,
        min_samples_leaf=10,
        alpha=1.0,
        gamma=1.0,
        n_components=100,
        max_features=None,
        scale_features=True,
        kernel_gammas=None,
        kernel_types=("rbf",),
        monotonic_cst=None,
    ):
        self.p_tree = p_tree
        self.p_linear = p_linear
        self.min_max_depth = min_max_depth
        self.max_max_depth = max_max_depth
        self.min_samples_leaf = min_samples_leaf
        self.alpha = alpha
        self.gamma = gamma
        self.n_components = n_components
        self.max_features = max_features
        self.scale_features = scale_features
        self.kernel_gammas = kernel_gammas
        self.kernel_types = kernel_types
        self.monotonic_cst = monotonic_cst

    def _validate_snapboost_params(self, overrides=None):
        overrides = {} if overrides is None else overrides
        p_tree = overrides.get("p_tree", self.p_tree)
        p_linear = overrides.get("p_linear", self.p_linear)
        min_max_depth = overrides.get("min_max_depth", self.min_max_depth)
        max_max_depth = overrides.get("max_max_depth", self.max_max_depth)
        min_samples_leaf = overrides.get(
            "min_samples_leaf", self.min_samples_leaf
        )
        alpha = overrides.get("alpha", self.alpha)
        gamma = overrides.get("gamma", self.gamma)
        n_components = overrides.get(
            "n_components", getattr(self, "n_components", None)
        )
        random_state = overrides.get("random_state", self.random_state)
        max_features = overrides.get("max_features", self.max_features)
        scale_features = overrides.get("scale_features", self.scale_features)
        kernel_gammas = overrides.get("kernel_gammas", self.kernel_gammas)
        kernel_types = overrides.get("kernel_types", self.kernel_types)
        monotonic_cst = overrides.get("monotonic_cst", self.monotonic_cst)

        if (
            isinstance(p_tree, (bool, np.bool_))
            or not isinstance(p_tree, Real)
            or not np.isfinite(p_tree)
            or not 0.0 <= p_tree <= 1.0
        ):
            raise ValueError(f"p_tree must be between 0 and 1, got {p_tree}.")
        if (
            isinstance(p_linear, (bool, np.bool_))
            or not isinstance(p_linear, Real)
            or not np.isfinite(p_linear)
            or not 0.0 <= p_linear <= 1.0
        ):
            raise ValueError(f"p_linear must be between 0 and 1, got {p_linear}.")
        if p_tree + p_linear > 1.0:
            raise ValueError("p_tree + p_linear must be <= 1.")
        if (
            isinstance(min_max_depth, (bool, np.bool_))
            or not isinstance(min_max_depth, Integral)
            or min_max_depth < 1
        ):
            raise ValueError(
                f"min_max_depth must be an integer >= 1, got {min_max_depth}."
            )
        if (
            isinstance(max_max_depth, (bool, np.bool_))
            or not isinstance(max_max_depth, Integral)
            or max_max_depth < 1
        ):
            raise ValueError(
                f"max_max_depth must be an integer >= 1, got {max_max_depth}."
            )
        if min_max_depth > max_max_depth:
            raise ValueError("min_max_depth must be <= max_max_depth.")
        if (
            isinstance(alpha, (bool, np.bool_))
            or not isinstance(alpha, Real)
            or not np.isfinite(alpha)
            or alpha <= 0
        ):
            raise ValueError(f"alpha must be a finite number > 0, got {alpha}.")
        if (
            isinstance(gamma, (bool, np.bool_))
            or not isinstance(gamma, Real)
            or not np.isfinite(gamma)
            or gamma <= 0
        ):
            raise ValueError(f"gamma must be a finite number > 0, got {gamma}.")
        if n_components is not None:
            if (
                isinstance(n_components, (bool, np.bool_))
                or not isinstance(n_components, Integral)
                or n_components < 1
            ):
                raise ValueError(
                    f"n_components must be an integer >= 1, got {n_components}."
                )
        if (
            isinstance(min_samples_leaf, (bool, np.bool_))
            or not isinstance(min_samples_leaf, Integral)
            or min_samples_leaf < 1
        ):
            raise ValueError(
                "min_samples_leaf must be an integer >= 1, "
                f"got {min_samples_leaf}."
            )
        if random_state is not None and (
            isinstance(random_state, (bool, np.bool_))
            or not isinstance(random_state, Integral)
            or random_state < 0
        ):
            raise ValueError(
                "random_state must be a non-negative integer or None, "
                f"got {random_state}."
            )
        if max_features is None:
            valid_max_features = True
        elif isinstance(max_features, str):
            valid_max_features = max_features in ("sqrt", "log2")
        elif isinstance(max_features, (bool, np.bool_)):
            valid_max_features = False
        elif isinstance(max_features, Integral):
            valid_max_features = max_features >= 1
        elif isinstance(max_features, Real):
            valid_max_features = (
                np.isfinite(max_features) and 0 < max_features <= 1
            )
        else:
            valid_max_features = False
        if not valid_max_features:
            raise ValueError(
                "max_features must be None, 'sqrt', 'log2', an integer >= 1, "
                "or a float in (0, 1]."
            )
        if not isinstance(scale_features, (bool, np.bool_)):
            raise ValueError("scale_features must be a boolean.")
        if kernel_gammas is not None and (
            isinstance(kernel_gammas, (str, bytes))
            or not isinstance(kernel_gammas, (Sequence, np.ndarray))
            or len(kernel_gammas) == 0
            or any(
                isinstance(value, (bool, np.bool_))
                or not isinstance(value, Real)
                or not np.isfinite(value)
                or value <= 0
                for value in kernel_gammas
            )
        ):
            raise ValueError("kernel_gammas must be a nonempty sequence of positive numbers or None.")
        if (
            isinstance(kernel_types, (str, bytes))
            or not isinstance(kernel_types, (Sequence, np.ndarray))
            or len(kernel_types) == 0
            or any(value not in ("rbf", "laplacian") for value in kernel_types)
        ):
            raise ValueError(
                "kernel_types must be a nonempty sequence containing 'rbf' "
                "and/or 'laplacian'."
            )
        if monotonic_cst is not None and (
            isinstance(monotonic_cst, (str, bytes))
            or not isinstance(monotonic_cst, (Sequence, np.ndarray))
            or any(value not in (-1, 0, 1) for value in monotonic_cst)
        ):
            raise ValueError(
                "monotonic_cst must contain only -1, 0, and 1, or be None."
            )

    def _learner_seeds(self, count):
        if self.random_state is None:
            return [None] * count
        return [
            int(seed)
            for seed in np.random.SeedSequence(self.random_state).generate_state(count)
        ]

    def _make_non_tree_learner(self, random_state, gamma, kernel):
        return RandomFourierRidgeRegressor(
            alpha=self.alpha,
            gamma=gamma,
            n_components=self.n_components,
            random_state=random_state,
            scale_features=self.scale_features,
            kernel=kernel,
        )

    def _make_linear_learner(self):
        return WeightedLinearRegressor(
            alpha=self.alpha,
            scale_features=self.scale_features,
        )

    def _build_base_learners(self):
        self.base_learners_ = []
        self.probabilities_ = []

        depth_range = range(self.min_max_depth, 1 + self.max_max_depth)
        gammas = (self.gamma,) if self.kernel_gammas is None else tuple(self.kernel_gammas)
        kernel_specs = [
            (kernel, gamma) for kernel in self.kernel_types for gamma in gammas
        ]
        linear_count = int(self.p_linear > 0.0)
        seeds = self._learner_seeds(
            len(depth_range) + len(kernel_specs) + linear_count
        )
        if self.p_tree > 0.0:
            tree_options = {}
            if self.monotonic_cst is not None:
                if "monotonic_cst" not in inspect.signature(
                    DecisionTreeRegressor
                ).parameters:
                    raise RuntimeError(
                        "monotonic_cst requires a scikit-learn version whose "
                        "DecisionTreeRegressor supports monotonic constraints."
                    )
                tree_options["monotonic_cst"] = self.monotonic_cst
            for d, tree_seed in zip(depth_range, seeds):
                self.base_learners_.append(
                    DecisionTreeRegressor(
                        max_depth=d,
                        min_samples_leaf=self.min_samples_leaf,
                        max_features=self.max_features,
                        random_state=tree_seed,
                        **tree_options,
                    )
                )
                self.probabilities_.append(self.p_tree / len(depth_range))

        kernel_probability = 1.0 - self.p_tree - self.p_linear
        if kernel_probability > 0.0:
            non_tree_probability = kernel_probability / len(kernel_specs)
            kernel_seeds = seeds[len(depth_range):]
            for (kernel, gamma), seed in zip(kernel_specs, kernel_seeds):
                self.base_learners_.append(
                    self._make_non_tree_learner(seed, gamma, kernel)
                )
                self.probabilities_.append(non_tree_probability)
        if self.p_linear > 0.0:
            self.base_learners_.append(self._make_linear_learner())
            self.probabilities_.append(self.p_linear)

    def _validate_set_param_keys(self, params):
        valid_params = self.get_params(deep=True)
        invalid = [key for key in params if key not in valid_params]
        if invalid:
            raise ValueError(
                f"Invalid parameter {invalid[0]} for estimator {self}. "
                "Check the list of available parameters with "
                "`estimator.get_params().keys()`."
            )

    def _set_snapboost_params(self, **params):
        self._validate_set_param_keys(params)
        rebuild = bool(self._REBUILD_PARAMS.intersection(params))
        self._validate_snapboost_params(params)
        result = super().set_params(**params)
        if rebuild:
            self._build_base_learners()
        return result

    def fit(
        self, X, y, sample_weight=None, eval_set=None,
        eval_metric=None, callbacks=None, candidate_n_jobs=1,
    ):
        self._validate_snapboost_params()
        self._build_base_learners()
        return super().fit(
            X,
            y,
            sample_weight=sample_weight,
            eval_set=eval_set,
            eval_metric=eval_metric,
            callbacks=callbacks,
            candidate_n_jobs=candidate_n_jobs,
        )


[docs] class SnapBoost(_SnapBoostMixin, HNBM): """ HNBM realization using decision trees and RFF ridge regressors. Prefer :class:`SnapBoostClassifier` or :class:`SnapBoostRegressor` for task-specific models without a ``mode`` parameter. Args: num_iterations (int): number of boosting iterations learning_rate (float): learning rate p_tree (float): probability of selecting a tree at each iteration min_max_depth (int): minimum maximum depth of a tree in the ensemble max_max_depth (int): maximum maximum depth of a tree in the ensemble min_samples_leaf (int): minimum samples per leaf for decision trees alpha (float): L2-regularization penalty in the ridge regression gamma (float): RBF-kernel parameter for random Fourier features n_components (int): number of random Fourier features mode (string): classification or regression random_state (int): random seed for tree fitting and learner selection verbose (bool): whether to show a progress bar during training """ def __init__( self, num_iterations=100, learning_rate=0.1, p_tree=0.9, p_linear=0.0, min_max_depth=2, max_max_depth=4, min_samples_leaf=10, alpha=1.0, gamma=1.0, n_components=100, mode="classification", random_state=None, verbose=False, ): self._init_snapboost_params( p_tree=p_tree, p_linear=p_linear, min_max_depth=min_max_depth, max_max_depth=max_max_depth, min_samples_leaf=min_samples_leaf, alpha=alpha, gamma=gamma, n_components=n_components, ) super().__init__(num_iterations, learning_rate, mode, random_state, verbose) self._validate_snapboost_params()
[docs] def set_params(self, **params): return self._set_snapboost_params(**params)
[docs] class SnapBoostClassifier(_SnapBoostMixin, HNBMClassifier): """ SnapBoost for binary classification. A heterogeneous Newton boosting machine that uses decision trees and random Fourier feature ridge regressors. Supports random or greedy learner selection, per-round line search, subsampling, observation weights, validation histories, and early stopping. """ def __init__( self, num_iterations=100, learning_rate=0.1, p_tree=0.9, p_linear=0.0, min_max_depth=2, max_max_depth=4, min_samples_leaf=10, alpha=1.0, gamma=1.0, n_components=100, max_features=None, scale_features=True, kernel_gammas=None, kernel_types=("rbf",), monotonic_cst=None, random_state=None, verbose=False, selection_strategy="random", line_search=False, early_stopping_rounds=None, min_delta=0.0, subsample=1.0, objective="auto", objective_parameter=None, ): self._init_snapboost_params( p_tree=p_tree, p_linear=p_linear, min_max_depth=min_max_depth, max_max_depth=max_max_depth, min_samples_leaf=min_samples_leaf, alpha=alpha, gamma=gamma, n_components=n_components, max_features=max_features, scale_features=scale_features, kernel_gammas=kernel_gammas, kernel_types=kernel_types, monotonic_cst=monotonic_cst, ) super().__init__( num_iterations=num_iterations, learning_rate=learning_rate, random_state=random_state, verbose=verbose, selection_strategy=selection_strategy, line_search=line_search, early_stopping_rounds=early_stopping_rounds, min_delta=min_delta, subsample=subsample, objective=objective, objective_parameter=objective_parameter, ) self._validate_snapboost_params()
[docs] def set_params(self, **params): if "mode" in params: raise ValueError("mode cannot be set on SnapBoostClassifier.") return self._set_snapboost_params(**params)
[docs] class SnapBoostRegressor(_SnapBoostMixin, HNBMRegressor): """ SnapBoost for regression. A heterogeneous Newton boosting machine that uses decision trees and random Fourier feature ridge regressors. Supports random or greedy learner selection, per-round line search, subsampling, observation weights, validation histories, and early stopping. """ def __init__( self, num_iterations=100, learning_rate=0.1, p_tree=0.9, p_linear=0.0, min_max_depth=2, max_max_depth=4, min_samples_leaf=10, alpha=1.0, gamma=1.0, n_components=100, max_features=None, scale_features=True, kernel_gammas=None, kernel_types=("rbf",), monotonic_cst=None, random_state=None, verbose=False, selection_strategy="random", line_search=False, early_stopping_rounds=None, min_delta=0.0, subsample=1.0, objective="auto", objective_parameter=None, ): self._init_snapboost_params( p_tree=p_tree, p_linear=p_linear, min_max_depth=min_max_depth, max_max_depth=max_max_depth, min_samples_leaf=min_samples_leaf, alpha=alpha, gamma=gamma, n_components=n_components, max_features=max_features, scale_features=scale_features, kernel_gammas=kernel_gammas, kernel_types=kernel_types, monotonic_cst=monotonic_cst, ) super().__init__( num_iterations=num_iterations, learning_rate=learning_rate, random_state=random_state, verbose=verbose, selection_strategy=selection_strategy, line_search=line_search, early_stopping_rounds=early_stopping_rounds, min_delta=min_delta, subsample=subsample, objective=objective, objective_parameter=objective_parameter, ) self._validate_snapboost_params()
[docs] def set_params(self, **params): if "mode" in params: raise ValueError("mode cannot be set on SnapBoostRegressor.") return self._set_snapboost_params(**params)
class _KernelRidgePoolMixin(_SnapBoostMixin): """SnapBoost learner pool using exact RBF kernel ridge regression.""" _REBUILD_PARAMS = _SnapBoostMixin._REBUILD_PARAMS - {"n_components"} def _init_kernel_ridge_params( self, *, p_tree, min_max_depth, max_max_depth, min_samples_leaf, alpha, gamma, ): self._init_snapboost_params( p_tree=p_tree, min_max_depth=min_max_depth, max_max_depth=max_max_depth, min_samples_leaf=min_samples_leaf, alpha=alpha, gamma=gamma, n_components=None, ) def _make_non_tree_learner(self, random_state, gamma, kernel): del random_state if kernel != "rbf": raise ValueError("Exact kernel ridge supports only the RBF kernel.") return KernelRidge(alpha=self.alpha, kernel="rbf", gamma=gamma) class SnapBoost_KernelRidge(_KernelRidgePoolMixin, HNBM): """ HNBM realization using decision trees and exact kernel ridge regressors. Args: num_iterations (int): number of boosting iterations learning_rate (float): learning rate p_tree (float): probability of selecting a tree at each iteration min_max_depth (int): minimum maximum depth of a tree in the ensemble max_max_depth (int): maximum maximum depth of a tree in the ensemble min_samples_leaf (int): minimum samples per leaf for decision trees alpha (float): L2-regularization penalty in the ridge regression gamma (float): RBF-kernel parameter mode (string): classification or regression random_state (int): random seed for tree fitting and learner selection verbose (bool): whether to show a progress bar during training """ def __init__(self, num_iterations=100, learning_rate=0.1, p_tree=0.9, min_max_depth=2, max_max_depth=4, min_samples_leaf=10, alpha=1.0, gamma=1.0, mode="classification", random_state=None, verbose=True): self._init_kernel_ridge_params( p_tree=p_tree, min_max_depth=min_max_depth, max_max_depth=max_max_depth, min_samples_leaf=min_samples_leaf, alpha=alpha, gamma=gamma, ) super().__init__(num_iterations, learning_rate, mode, random_state, verbose) self._validate_snapboost_params() def set_params(self, **params): return self._set_snapboost_params(**params) class SnapBoostKernelRidgeClassifier(_KernelRidgePoolMixin, HNBMClassifier): """Binary SnapBoost classifier using exact RBF kernel ridge learners.""" def __init__( self, num_iterations=100, learning_rate=0.1, p_tree=0.9, min_max_depth=2, max_max_depth=4, min_samples_leaf=10, alpha=1.0, gamma=1.0, random_state=None, verbose=False, ): self._init_kernel_ridge_params( p_tree=p_tree, min_max_depth=min_max_depth, max_max_depth=max_max_depth, min_samples_leaf=min_samples_leaf, alpha=alpha, gamma=gamma, ) super().__init__( num_iterations=num_iterations, learning_rate=learning_rate, random_state=random_state, verbose=verbose, ) self._validate_snapboost_params() def set_params(self, **params): if "mode" in params: raise ValueError("mode cannot be set on SnapBoostKernelRidgeClassifier.") return self._set_snapboost_params(**params) class SnapBoostKernelRidgeRegressor(_KernelRidgePoolMixin, HNBMRegressor): """SnapBoost regressor using exact RBF kernel ridge learners.""" def __init__( self, num_iterations=100, learning_rate=0.1, p_tree=0.9, min_max_depth=2, max_max_depth=4, min_samples_leaf=10, alpha=1.0, gamma=1.0, random_state=None, verbose=False, ): self._init_kernel_ridge_params( p_tree=p_tree, min_max_depth=min_max_depth, max_max_depth=max_max_depth, min_samples_leaf=min_samples_leaf, alpha=alpha, gamma=gamma, ) super().__init__( num_iterations=num_iterations, learning_rate=learning_rate, random_state=random_state, verbose=verbose, ) self._validate_snapboost_params() def set_params(self, **params): if "mode" in params: raise ValueError("mode cannot be set on SnapBoostKernelRidgeRegressor.") return self._set_snapboost_params(**params)