Source code for pretab.transformers.splines.cubic

import numpy as np
from sklearn.base import BaseEstimator, TransformerMixin
from sklearn.utils.validation import check_is_fitted

from ...core.exceptions import InvalidParamError
from ...core.params import UNSET, validate_placement
from .knot_selectors import build_knot_selector
from .mixins import SplineBasisMixin


[docs] class CubicSplineTransformer(SplineBasisMixin, TransformerMixin, BaseEstimator): r""" Cubic Spline Transformer for one-dimensional or multi-dimensional input features. This transformer applies a truncated-power cubic spline basis to each continuous feature. The basis stacks the polynomial terms :math:`x, x^2, x^3` with one truncated cubic term :math:`(x - \kappa)^3_+` per interior knot :math:`\kappa`. Optionally a bias (intercept) column is prepended. Let :math:`m := \mathtt{output\_dim}` be the number of non-bias output columns produced per feature. A cubic truncated-power basis with :math:`K` interior knots has .. math:: m = 3 + K, so the requested ``output_dim`` is inverted to :math:`K = \mathtt{output\_dim} - 3` interior knots. ``include_bias=True`` adds one further column on top of ``output_dim``. Parameters ---------- output_dim : int, default=6 Number of non-bias output columns per feature (:math:`m`). Must be at least 3 (the three polynomial terms; ``output_dim == 3`` places no interior knots). The number of interior knots is ``output_dim - 3``. degree : int, default=3 Degree of the polynomial spline. Currently fixed to 3 (cubic), included for compatibility. include_bias : bool, default=False Whether to include a bias (intercept) term in the output feature set. target_aware : bool, default=False If True, interior knots are placed by a target-aware selector built from ``placement_strategy`` (requires ``y`` during ``fit``). If False, knots use the unsupervised ``placement_strategy`` spacing. placement_strategy : {"cart", "lightgbm", "uniform", "quantile"}, default="uniform" When ``target_aware=True``, the selector: ``"cart"`` or ``"lightgbm"``. When ``target_aware=False``, the spacing: ``"uniform"`` spaces knots evenly across the range, ``"quantile"`` places them at evenly spaced data quantiles. task : {"regression", "classification"} or None, default=None Task forwarded to the target-aware selector when ``target_aware=True``. adaptive : bool, default=False If True (with ``target_aware=True``), the per-feature output dimension may vary within ``[min_output_dim, max_output_dim]`` instead of being fixed to ``output_dim``. min_output_dim : int or None, default=None Lower bound on the per-feature output dimension in adaptive mode. max_output_dim : int or None, default=None Upper bound on the per-feature output dimension in adaptive mode. random_state : int or None, default=None Random state forwarded to the target-aware selector for reproducibility. Attributes ---------- knots_ : list of ndarray Interior knots used for each feature (length ``output_dim - 3`` on the default, non-selector path). n_knots_ : list of int Number of interior knots placed for each feature (``len(knots_[i])``). designs_ : list of ndarray Cached design matrices (spline basis evaluations) for each input feature, each of shape ``(n_samples, output_dim (+1 if include_bias))``. n_basis_ : list of int Number of output columns per feature, including the optional bias. n_features_in_ : int Number of input features seen during ``fit``. total_output_dim_ : int Total number of output columns across all features (fitted); equals ``n_features * (output_dim (+1 if include_bias))``. Notes ----- The basis includes the polynomial terms ``x, x^2, x^3`` followed by the truncated power terms ``(x - knot)^3_+`` for each interior knot. Each feature is expanded independently and the results are stacked horizontally. Examples -------- >>> import numpy as np >>> from pretab.transformers import CubicSplineTransformer >>> X = np.linspace(0, 1, 20).reshape(-1, 1) >>> transformer = CubicSplineTransformer(output_dim=8) >>> Xt = transformer.fit_transform(X) >>> Xt.shape (20, 8) >>> transformer.n_knots_ [5] >>> transformer.total_output_dim_ 8 """ _feature_suffix_value = "cs"
[docs] def __init__( self, output_dim=UNSET, degree=3, include_bias=False, target_aware: bool = False, placement_strategy: str = "uniform", task=None, adaptive: bool = False, min_output_dim=UNSET, max_output_dim=UNSET, random_state: int | None = None, ): self.output_dim = output_dim self.degree = degree self.include_bias = include_bias self.target_aware = target_aware self.placement_strategy = placement_strategy self.task = task self.adaptive = adaptive self.min_output_dim = min_output_dim self.max_output_dim = max_output_dim self.random_state = random_state
def _bspline_basis(self, x, knots): x = np.asarray(x).reshape(-1, 1) n_samples = x.shape[0] X = [np.ones((n_samples, 1))] if self.include_bias else [] X.append(x) X.append(x**2) X.append(x**3) for knot in knots: X.append(np.maximum(0, (x - knot)) ** 3) return np.hstack(X) def fit(self, X, y=None): validate_placement(self.target_aware, self.placement_strategy) X = self._validate_allow_nan(X, reset=True) output_dim = self._resolve_param("output_dim", default=6) if output_dim < 3: raise InvalidParamError(f"output_dim must be >= 3 for the cubic spline basis, got {output_dim}") n_interior = output_dim - 3 if self.target_aware: selector = build_knot_selector( self.placement_strategy, degree=self.degree, spline_type="bspline", random_state=self.random_state, ) strategy = "uniform" else: selector = None strategy = self.placement_strategy min_interior, max_interior = self._adaptive_interior_bounds( output_dim, selector, floor=3, offset=3 ) self.knots_ = [] self.designs_ = [] for i in range(X.shape[1]): xi = X[:, i] knots = self._place_interior_knots( xi, y, n_interior, strategy, selector, self.task, min_interior, max_interior ) self.knots_.append(knots) self.designs_.append(self._bspline_basis(xi, knots)) self.n_basis_ = [design.shape[1] for design in self.designs_] self.n_knots_ = [len(knots) for knots in self.knots_] return self def transform(self, X): check_is_fitted(self, "n_basis_") X = self._validate_allow_nan(X, reset=False) transformed = [] for i in range(X.shape[1]): xi = X[:, i] design = self._bspline_basis(xi, self.knots_[i]) transformed.append(design) return np.hstack(transformed)
[docs] def fit_transform(self, X, y=None): return self.fit(X, y).transform(X)
[docs] def get_penalty_matrix(self, feature_index=0): """Return the curvature penalty matrix for a fitted feature. Parameters ---------- feature_index : int, default=0 Index of the feature whose penalty matrix is returned. Returns ------- P : ndarray of shape (n_basis, n_basis) Penalty matrix that penalizes the second derivative (curvature) of the spline basis for smoothness. """ check_is_fitted(self, "designs_") n_basis = self.designs_[feature_index].shape[1] P = np.zeros((n_basis, n_basis)) offset = 4 if self.include_bias else 3 for i in range(offset, n_basis): for j in range(offset, n_basis): ki = self.knots_[feature_index][i - offset] kj = self.knots_[feature_index][j - offset] P[i, j] = self._spline_penalty_entry(ki, kj, self.knots_[feature_index]) return P
def _spline_penalty_entry(self, ki, kj, knots): kmax = max(ki, kj) upper = knots[-1] x_vals = np.linspace(kmax, upper, 100) integrand = 36 * (x_vals - ki) * (x_vals - kj) return np.trapezoid(integrand, x_vals)