Source code for mojolearn._gbdt_adapters

# SPDX-License-Identifier: Apache-2.0
"""Bounded sklearn adapters around the existing GPU GBDT learner.

Legacy GradientBoosting.predict remains raw prediction. These adapters support
binary Logloss classification and RMSE regression, numeric features, and pickle
state. Evaluation features must already have the same preprocessing as X;
sklearn Pipeline does not automatically transform a supplied eval_set.
"""
import inspect

from ._array import Array
from ._buffer import materialize_f32_lists, all_finite, empty, as_f32_c
from ._labels import decode_labels

from . import _backend, _metrics_impl as metrics
from ._arrays import _addr, _addr_ro
from .ensemble import GradientBoosting


class NotFittedError(ValueError, AttributeError):
    """Raised before fit, with scikit-learn's `NotFittedError` bases
    (ValueError and AttributeError), so callers that catch either keep
    working without this module importing scikit-learn."""


class _GBDTAdapter:
    _loss = None

    # Every default is GradientBoosting's own, so a default adapter is the
    # default learner: None defers to CatBoost's GPU defaults under
    # SymmetricTree (n_estimators 1000, the auto learning rate, l2 3.0,
    # random_strength 1.0, the Bayesian bootstrap) and to the earlier ones
    # under Depthwise and Lossguide (lane/catboost-parity, 2026-09-19; see
    # GradientBoosting). l2_leaf_reg is None rather than 3.0 because an
    # explicit l2 turns CatBoost's learning-rate auto-selection off.
    def __init__(self, *, n_estimators=None, max_depth=6, learning_rate=None,
                 l2_leaf_reg=None, border_count=128, random_state=0,
                 leaf_estimation_method=None, leaf_estimation_iterations=None,
                 bootstrap_type=None, bagging_temperature=1.0, subsample=None,
                 od_type=None, od_pvalue=None, od_wait=None, use_best_model=None,
                 best_model_min_trees=1, score_function=None, nan_mode='Min',
                 random_strength=None, use_pointwise_searcher=False,
                 boost_from_average=None, border_build_max_samples=200000,
                 class_weights=None, grow_policy='SymmetricTree', max_leaves=None,
                 min_data_in_leaf=1, min_split_gain=None, min_child_hessian=None,
                 feature_fraction=1.0, feature_border_type='GreedyLogSum',
                 boosting_type=None, fold_len_multiplier=2.0,
                 fold_permutation_block=None, numeric_mode=None):
        values = locals().copy()
        values.pop('self')
        for name, value in values.items():  # glue: stores constructor keyword arguments
            setattr(self, name, value)
        self._new_learner(numeric_mode)

    def _new_learner(self, mode):
        if mode is not None and (not isinstance(mode, str) or
                mode.strip().lower() not in ('fast', 'deterministic', 'identical')):
            raise ValueError('numeric_mode must be fast, deterministic, identical or None')
        if self._loss == 'RMSE' and self.class_weights is not None:
            raise NotImplementedError('GradientBoostingRegressor does not support class_weights')
        parameters = self.get_params()
        parameters['numeric_mode'] = mode
        return GradientBoosting(loss=self._loss, **parameters)

    def get_params(self, deep=True):
        """Return the constructor parameters as a dict."""
        if type(self).__init__ is not _GBDTAdapter.__init__:
            raise TypeError('GBDT adapter subclasses with custom constructors need their own parameter protocol')
        names = inspect.signature(_GBDTAdapter.__init__).parameters
        return {name: getattr(self, name) for name in names if name != 'self'}  # glue: returns constructor parameters by name

    def set_params(self, **params):
        """Set constructor parameters and return ``self``; unknown names raise ``ValueError``."""
        if not params:
            return self
        values = self.get_params()
        unknown = sorted(set(params) - values.keys())  # glue: names unknown keyword parameters
        if unknown:
            raise ValueError(f'Invalid {type(self).__name__} parameters: {unknown}')
        values.update(params)
        replacement = type(self)(**values)
        self.__dict__.clear()
        self.__dict__.update(replacement.__dict__)
        return self

    def _clear_fit(self):
        for name in list(self.__dict__):  # glue: clears fitted attribute names
            if name.endswith('_'):
                del self.__dict__[name]

    def _check_fitted(self):
        if not self.__sklearn_is_fitted__():
            raise NotFittedError(f'{type(self).__name__} is not fitted')

    def __sklearn_is_fitted__(self):
        return getattr(getattr(self, '_learner_', None), 'model_', None) is not None

    def __sklearn_tags__(self):
        """scikit-learn's tag protocol, read only by scikit-learn: the shared
        `_mode.ParamsMixin` answer, with the classifier marked binary-only
        (these adapters fit binary Logloss)."""
        from ._mode import ParamsMixin
        tags = ParamsMixin.__sklearn_tags__(self)
        if tags.classifier_tags is not None:
            tags.classifier_tags.multi_class = False
        return tags

    def _fit_native(self, X, y, sample_weight, eval_set):
        raw_mode = self.numeric_mode if self.numeric_mode is not None else _backend.default_mode()
        learner = self._new_learner(raw_mode)
        mode = raw_mode.strip().lower()
        learner.numeric_mode = mode
        binding = _backend.binding('_mojolearn_gbdt', mode)
        if binding.gbdt_numeric_mode() != {'fast': 0, 'identical': 1, 'deterministic': 2}[mode]:
            raise RuntimeError('GBDT adapter native numeric mode disagrees with requested mode')
        learner.fit(X, y, sample_weight=sample_weight, eval_set=eval_set)
        self._learner_ = learner
        self.numeric_mode_ = mode
        self.n_features_in_ = learner.n_features_in_
        self.model_ = learner.model_
        for name in ('loss_curve_', 'test_loss_curve_', 'best_iteration_', 'stopped_early_',  # glue: copies five learner attributes
                     'learning_rate_'):
            setattr(self, name, getattr(learner, name))
        return self

    def save(self, path):
        """Not available for this estimator; raises ``NotImplementedError``. Use pickle."""
        raise NotImplementedError('GBDT adapter archives are not implemented; pickle retains adapter state')

    @classmethod
    def load(cls, path):
        """Not available for this estimator; raises ``NotImplementedError``. Use pickle."""
        raise NotImplementedError('Legacy raw GBDT archives have no adapter vocabulary/parameters')


[docs] class GradientBoostingClassifier(_GBDTAdapter): """Binary gradient-boosted tree classifier with a scikit-learn interface. Fits :class:`GradientBoosting` with ``loss='Logloss'`` on numeric features and keeps the original class labels. Exactly two classes are required; the positive class is the larger of the two sorted labels. Supports sample weights in ``fit`` and ``score``, and pickling. ``save`` and ``load`` are not available; use pickle. Parameters ---------- n_estimators, max_depth, learning_rate, l2_leaf_reg, border_count, random_state As in :class:`GradientBoosting`; every default is that class's default, so ``n_estimators=None`` means 1000 iterations and an automatically chosen learning rate under ``grow_policy='SymmetricTree'``. leaf_estimation_method, leaf_estimation_iterations, bootstrap_type, bagging_temperature, subsample As in :class:`GradientBoosting`. od_type, od_pvalue, od_wait, use_best_model, best_model_min_trees Overfitting detector and best-model truncation, as in :class:`GradientBoosting`; they need ``eval_set`` in ``fit``. score_function, nan_mode, random_strength, use_pointwise_searcher As in :class:`GradientBoosting`. boost_from_average, border_build_max_samples, class_weights As in :class:`GradientBoosting`. grow_policy, max_leaves, min_data_in_leaf, min_split_gain, min_child_hessian As in :class:`GradientBoosting`. feature_fraction, feature_border_type, boosting_type, fold_len_multiplier, fold_permutation_block As in :class:`GradientBoosting`. numeric_mode : {'identical', 'deterministic', 'fast'} or None, default None Numeric mode for this estimator (``'fast'`` on Apple GPUs); None uses the process default. Any other value raises ``ValueError``. All parameters are keyword-only. Attributes ---------- classes_ : list The two class labels, sorted. n_classes_ : int Always 2. n_features_in_ : int model_ : str The fitted model text. loss_curve_, test_loss_curve_, best_iteration_, stopped_early_, learning_rate_ As on :class:`GradientBoosting`. numeric_mode_ : str The numeric mode the fit used. Examples -------- >>> import numpy as np, mojolearn >>> rng = np.random.default_rng(0) >>> X = rng.random((1000, 8), dtype=np.float32) >>> y = np.where(X[:, 0] > 0.5, "yes", "no") >>> clf = mojolearn.GradientBoostingClassifier(n_estimators=100).fit(X, y) >>> labels = clf.predict(X) """ _loss = 'Logloss' _estimator_type = 'classifier'
[docs] def fit(self, X, y, sample_weight=None, eval_set=None): """Fit the classifier. Parameters ---------- X : array-like of shape (n_samples, n_features) Numeric features. y : array-like of shape (n_samples,) Labels with exactly two distinct values; otherwise ``ValueError``. sample_weight : array-like of shape (n_samples,), optional eval_set : tuple (X_eval, y_eval) or list of one such tuple, optional Held-out data for the overfitting detector and ``use_best_model``. Its labels must be among the training labels, and ``X_eval`` must already have the same preprocessing as ``X`` (a scikit-learn ``Pipeline`` does not transform it for you). Returns ------- self """ self._clear_fit() target, kind = metrics._classification_encoded(y, 'y') classes = sorted(metrics._label_set(target)) # glue: orders the binary class labels (two expected) if len(classes) != 2: raise ValueError('GradientBoostingClassifier requires exactly two training classes') vocabulary = {label: i for i, label in enumerate(classes)} # glue: two-label binary vocabulary encoded = metrics._label_map(target, vocabulary.__getitem__).astype('<f4') if eval_set is not None: if isinstance(eval_set, list): if len(eval_set) != 1: raise ValueError('eval_set accepts one (X, y) pair') eval_set = eval_set[0] if not isinstance(eval_set, tuple) or len(eval_set) != 2: raise ValueError('eval_set must be (X_eval, y_eval) or a one-pair list') eval_X, eval_y = eval_set eval_labels, eval_kind = metrics._classification_encoded(eval_y, 'eval_set y') if eval_kind != kind or any(label not in vocabulary for label in metrics._label_set(eval_labels)): # glue: validates eval_set labels against the binary vocabulary raise ValueError('eval_set contains labels outside the training vocabulary') eval_set = (eval_X, metrics._label_map(eval_labels, vocabulary.__getitem__).astype('<f4')) self._fit_native(X, encoded, sample_weight, eval_set) self.classes_ = classes self.n_classes_ = 2 self._label_kind_ = kind return self
[docs] def decision_function(self, X): """Return the raw float32 margin for each row. A positive margin favors the positive (larger) class; a zero margin predicts the negative class. """ self._check_fitted() return self._learner_.predict(X)
def _binary_output(self, X, probabilities): if not probabilities and self.numeric_mode_ == 'fast': return self._learner_.predict_classes(X) margins = as_f32_c(self.decision_function(X), ndim=1, name="margins")[0] if margins.ndim != 1 or not all_finite(margins): raise ValueError('Classifier margins must be finite scalar Float32 values') n = len(margins) binding = _backend.binding('_mojolearn_gbdt', self.numeric_mode_) if probabilities: output = empty((n, 2), '<f4') wrote = binding.gbdt_binary_probabilities(_addr_ro(margins), _addr(output), [n]) else: output = empty(n, '<i4') wrote = binding.gbdt_binary_classes(_addr_ro(margins), _addr(output), [n]) if wrote != (2 * n if probabilities else n): raise RuntimeError('GBDT binary output returned an unexpected row count') return output
[docs] def predict_proba(self, X): """Return float32 probabilities of shape ``(n_samples, 2)``. Columns are ``[negative, positive]`` in ``classes_`` order. """ return self._binary_output(X, True)
[docs] def predict(self, X): """Return the predicted class labels, using the original label values.""" self._check_fitted() return decode_labels(self.classes_, self._binary_output(X, False))
# The accuracy is metrics.accuracy_score on the pinned-sum path.
[docs] def score(self, X, y, sample_weight=None): """Return the accuracy on ``X`` and ``y``, weighted by ``sample_weight`` when given. Follows scikit-learn's weighted accuracy. ``y`` must have the same label type as the training labels; otherwise ``TypeError``. """ self._check_fitted() target, kind = metrics._classification_encoded(y, 'y') if kind != self._label_kind_: raise TypeError('score labels must have the training label type') vocabulary = {label: i for i, label in enumerate(self.classes_)} # glue: two-label binary vocabulary encoded = metrics._label_map(target, lambda label: vocabulary.get(label, -1)) return metrics.accuracy_score(encoded, self._binary_output(X, False), sample_weight=sample_weight, numeric_mode=self.numeric_mode_)
[docs] class GradientBoostingRegressor(_GBDTAdapter): """Gradient-boosted tree regressor with a scikit-learn interface. Fits :class:`GradientBoosting` with ``loss='RMSE'`` on numeric features. Predictions are float32. Supports sample weights in ``fit`` and ``score``, and pickling. ``class_weights`` raises ``NotImplementedError``. ``save`` and ``load`` are not available; use pickle. Parameters ---------- n_estimators, max_depth, learning_rate, l2_leaf_reg, border_count, random_state As in :class:`GradientBoosting`; every default is that class's default, so ``n_estimators=None`` means 1000 iterations and an automatically chosen learning rate under ``grow_policy='SymmetricTree'``. leaf_estimation_method, leaf_estimation_iterations, bootstrap_type, bagging_temperature, subsample As in :class:`GradientBoosting`. od_type, od_pvalue, od_wait, use_best_model, best_model_min_trees Overfitting detector and best-model truncation, as in :class:`GradientBoosting`; they need ``eval_set`` in ``fit``. score_function, nan_mode, random_strength, use_pointwise_searcher As in :class:`GradientBoosting`. boost_from_average, border_build_max_samples, class_weights As in :class:`GradientBoosting`. grow_policy, max_leaves, min_data_in_leaf, min_split_gain, min_child_hessian As in :class:`GradientBoosting`. feature_fraction, feature_border_type, boosting_type, fold_len_multiplier, fold_permutation_block As in :class:`GradientBoosting`. numeric_mode : {'identical', 'deterministic', 'fast'} or None, default None Numeric mode for this estimator (``'fast'`` on Apple GPUs); None uses the process default. Any other value raises ``ValueError``. All parameters are keyword-only. Attributes ---------- n_features_in_ : int model_ : str The fitted model text. loss_curve_, test_loss_curve_, best_iteration_, stopped_early_, learning_rate_ As on :class:`GradientBoosting`. numeric_mode_ : str The numeric mode the fit used. Examples -------- >>> import numpy as np, mojolearn >>> rng = np.random.default_rng(0) >>> X = rng.random((1000, 8), dtype=np.float32) >>> y = (X[:, 0] * 2 + X[:, 1]).astype(np.float32) >>> reg = mojolearn.GradientBoostingRegressor(n_estimators=100).fit(X, y) >>> r2 = reg.score(X, y) """ _loss = 'RMSE' _estimator_type = 'regressor'
[docs] def fit(self, X, y, sample_weight=None, eval_set=None): """Fit the regressor. Parameters ---------- X : array-like of shape (n_samples, n_features) Numeric features. y : array-like of shape (n_samples,) Finite, nonempty float32 targets. sample_weight : array-like of shape (n_samples,), optional eval_set : tuple (X_eval, y_eval) or list of one such tuple, optional Held-out data for the overfitting detector and ``use_best_model``. ``X_eval`` must already have the same preprocessing as ``X``. Returns ------- self """ self._clear_fit() target = materialize_f32_lists(y, "y")[0] self._regression_target(target, 'y') if eval_set is not None: if isinstance(eval_set, list): if len(eval_set) != 1: raise ValueError('eval_set accepts one (X, y) pair') eval_set = eval_set[0] if not isinstance(eval_set, tuple) or len(eval_set) != 2: raise ValueError('eval_set must be (X_eval, y_eval) or a one-pair list') eval_X, eval_y = eval_set eval_target = materialize_f32_lists(eval_y, "eval_set y")[0] self._regression_target(eval_target, 'eval_set y') eval_set = (eval_X, eval_target) return self._fit_native(X, target, sample_weight, eval_set)
@staticmethod def _regression_target(target, name): if target.ndim != 1 or target.size == 0: raise ValueError(f'{name} must be nonempty one-dimensional targets') if target.dtype != "<f4": raise TypeError(f'{name} must have dtype float32') if not all_finite(target): raise ValueError(f'{name} must be finite')
[docs] def predict(self, X): """Return float32 predictions of shape ``(n_samples,)``.""" self._check_fitted() return self._learner_.predict(X)
# R2 is metrics.r2_score on the pinned-sum path.
[docs] def score(self, X, y, sample_weight=None): """Return the R² score on ``X`` and ``y``, weighted by ``sample_weight`` when given. Follows scikit-learn's weighted ``r2_score``. """ self._check_fitted() target = materialize_f32_lists(y, "y")[0] self._regression_target(target, 'y') return metrics.r2_score(target, self.predict(X), sample_weight=sample_weight, numeric_mode=self.numeric_mode_)