Source code for snapboost.rff_learner

from numbers import Integral, Real

import numpy as np
from sklearn.base import BaseEstimator, RegressorMixin, TransformerMixin
from sklearn.kernel_approximation import RBFSampler
from sklearn.linear_model import Ridge
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.utils.validation import check_array, check_is_fitted, check_X_y


class LaplacianSampler(BaseEstimator, TransformerMixin):
    """Random Fourier features for the Laplacian (L1 exponential) kernel."""

    def __init__(self, gamma=1.0, n_components=100, random_state=None):
        self.gamma = gamma
        self.n_components = n_components
        self.random_state = random_state

    def fit(self, X, y=None):
        X = check_array(X, dtype=float)
        rng = np.random.default_rng(self.random_state)
        self.random_weights_ = rng.standard_cauchy(
            size=(X.shape[1], self.n_components)
        ) * self.gamma
        self.random_offset_ = rng.uniform(0.0, 2.0 * np.pi, self.n_components)
        self.n_features_in_ = X.shape[1]
        return self

    def transform(self, X):
        check_is_fitted(self, ["random_weights_", "random_offset_"])
        X = check_array(X, dtype=float)
        if X.shape[1] != self.n_features_in_:
            raise ValueError(
                f"X has {X.shape[1]} features, but sampler was trained with "
                f"{self.n_features_in_}."
            )
        projection = X @ self.random_weights_ + self.random_offset_
        return np.sqrt(2.0 / self.n_components) * np.cos(projection)


[docs] class RandomFourierRidgeRegressor(BaseEstimator, RegressorMixin): """ Ridge regression on random Fourier features approximating an RBF kernel. Matches the linear + RFF base learner used in the original SnapBoost paper, scaling linearly in the number of samples instead of exact KernelRidge. Features are standardized by default because RBF distances are sensitive to input scale. Set ``scale_features=False`` for pre-scaled inputs. """ def __init__( self, alpha=1.0, gamma=1.0, n_components=100, random_state=None, scale_features=True, kernel="rbf", ): self.alpha = alpha self.gamma = gamma self.n_components = n_components self.random_state = random_state self.scale_features = scale_features self.kernel = kernel def _make_pipeline(self): steps = [] if self.scale_features: steps.append(("scale", StandardScaler())) if self.kernel == "rbf": sampler = RBFSampler( gamma=self.gamma, n_components=self.n_components, random_state=self.random_state, ) else: sampler = LaplacianSampler( gamma=self.gamma, n_components=self.n_components, random_state=self.random_state, ) steps.extend([ ( "rff", sampler, ), ("ridge", Ridge(alpha=self.alpha)), ]) return Pipeline(steps) def _validate_params(self): if not isinstance(self.scale_features, (bool, np.bool_)): raise ValueError("scale_features must be a boolean.") if self.kernel not in ("rbf", "laplacian"): raise ValueError("kernel must be 'rbf' or 'laplacian'.") if ( isinstance(self.alpha, (bool, np.bool_)) or not isinstance(self.alpha, Real) or not np.isfinite(self.alpha) or self.alpha <= 0 ): raise ValueError( f"alpha must be a finite number > 0, got {self.alpha}." ) if ( isinstance(self.gamma, (bool, np.bool_)) or not isinstance(self.gamma, Real) or not np.isfinite(self.gamma) or self.gamma <= 0 ): raise ValueError( f"gamma must be a finite number > 0, got {self.gamma}." ) if ( isinstance(self.n_components, (bool, np.bool_)) or not isinstance(self.n_components, Integral) or self.n_components < 1 ): raise ValueError( "n_components must be an integer >= 1, " f"got {self.n_components}." ) if self.random_state is not None and ( isinstance(self.random_state, (bool, np.bool_)) or not isinstance(self.random_state, Integral) or not 0 <= self.random_state <= np.iinfo(np.uint32).max ): raise ValueError( "random_state must be an integer between 0 and 2**32 - 1 " f"or None, got {self.random_state}." )
[docs] def fit(self, X, y, sample_weight=None): self._validate_params() X, y = check_X_y(X, y, dtype=float, y_numeric=True) if sample_weight is not None: sample_weight = np.asarray(sample_weight, dtype=float) if sample_weight.ndim == 0: sample_weight = np.full(X.shape[0], sample_weight.item()) if sample_weight.ndim != 1 or sample_weight.shape[0] != X.shape[0]: raise ValueError( "sample_weight must be a scalar or a one-dimensional array " "with one value per sample." ) if not np.all(np.isfinite(sample_weight)): raise ValueError("sample_weight must contain only finite values.") if np.any(sample_weight < 0): raise ValueError("sample_weight must be non-negative.") weight_sum = sample_weight.sum() if weight_sum <= 0: raise ValueError("sample_weight must have a positive total weight.") # Ridge regularization depends on the absolute weight scale. Normalize # over observations that contribute to the fit so uniformly scaling # weights has no effect and zero-weight rows are equivalent to removal. positive_weight_mask = sample_weight > 0 positive_weight_count = np.count_nonzero(positive_weight_mask) sample_weight = sample_weight * (positive_weight_count / weight_sum) X = X[positive_weight_mask] y = y[positive_weight_mask] sample_weight = sample_weight[positive_weight_mask] pipeline = self._make_pipeline() fit_params = {} if sample_weight is not None: fit_params["ridge__sample_weight"] = sample_weight if self.scale_features: fit_params["scale__sample_weight"] = sample_weight pipeline.fit(X, y, **fit_params) self.pipeline_ = pipeline self.n_features_in_ = X.shape[1] return self
[docs] def predict(self, X): check_is_fitted(self, ["pipeline_", "n_features_in_"]) X = check_array(X, dtype=float) if X.shape[1] != self.n_features_in_: raise ValueError( f"X has {X.shape[1]} features, but model was trained with " f"{self.n_features_in_} features." ) return self.pipeline_.predict(X)