Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion docs/representations/choosing_a_method.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,11 @@ PreTab is explicit about degenerate inputs rather than failing silently.
values beyond the fitted range by default; natural-cubic and cubic-regression extrapolate
smoothly by default. Pass `policy=RepresentationPolicy(out_of_range=...)` directly to any
of these transformers to override its default with `"clip"`, `"warn"`, or `"error"`
instead. This is standalone-transformer-only for now, not yet available through
instead, or with `"extrapolate"` for the clipping families: `"extrapolate"` and `"warn"`
evaluate an out-of-range value on the extended boundary polynomial pieces of the basis (an
I-spline stays at 0 below and 1 above the range). A mapping such as
`{"constant": "warn"}` changes only the axes it names and keeps the family's other
defaults. This is standalone-transformer-only for now, not yet available through
`Preprocessor`.
- **Unseen category**: `ContinuousOrdinalTransformer` (`"int"`) maps an unseen category to a
reserved code rather than raising; one-hot encoding (`"one-hot"`) instead emits an all-zero
Expand Down
23 changes: 14 additions & 9 deletions pretab/core/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,19 +58,24 @@ class BasePreTabTransformer(
def _resolved_policy(self) -> RepresentationPolicy:
"""Return the effective edge-case policy for this instance.

An explicit ``policy`` constructor argument, on the transformers that expose
one, always wins verbatim over the class-level defaults below. Otherwise, the
shared default policy is narrowed by this family's ``_constant_policy`` /
``_out_of_range_policy`` class attributes, which record each family's
historical, non-configurable default behavior.
The shared default policy is narrowed by this family's ``_constant_policy``
/ ``_out_of_range_policy`` class attributes, which record each family's
historical default behavior. An explicit ``policy`` constructor argument, on
the transformers that expose one, then applies on top: a mapping overrides
only the axes it names (so ``{"constant": "error"}`` keeps the family's
out-of-range handling), and a :class:`RepresentationPolicy` instance is used
verbatim.
"""
instance_policy = getattr(self, "policy", None)
if instance_policy is not None:
return RepresentationPolicy.resolve(instance_policy)
return self._policy.merge(
family_policy = self._policy.merge(
constant=self._constant_policy,
out_of_range=self._out_of_range_policy,
)
instance_policy = getattr(self, "policy", None)
if instance_policy is None:
return family_policy
if isinstance(instance_policy, dict):
return family_policy.merge(**instance_policy)
return RepresentationPolicy.resolve(instance_policy)

def _validate(self, X, *, reset: bool):
"""Validate ``X`` through the shared NaN-aware validator."""
Expand Down
18 changes: 18 additions & 0 deletions pretab/core/knots.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
__all__ = [
"basis_to_knots",
"bspline_basis",
"extrapolate_bspline_rows",
"generate_internal_knots",
"quantile_knots",
"select_knots",
Expand Down Expand Up @@ -57,6 +58,23 @@ def bspline_basis(x: np.ndarray, knots: np.ndarray, degree: int, i: int, last: i
return term1 + term2


def extrapolate_bspline_rows(basis: np.ndarray, x: np.ndarray, knots: np.ndarray, degree: int) -> np.ndarray:
"""Fill the rows of ``basis`` whose ``x`` lies outside the knot span by extrapolation.

:func:`bspline_basis` is zero outside ``[knots[0], knots[-1]]``. When a policy lets
out-of-range values through (``"extrapolate"`` / ``"warn"``), those rows are
evaluated instead by extending the boundary polynomial pieces, which keeps the
basis a partition of unity there. Rows inside the span are left untouched.
"""
outside = (x < knots[0]) | (x > knots[-1])
if outside.any():
from scipy.interpolate import BSpline

n_basis = len(knots) - degree - 1
basis[outside] = BSpline(knots, np.eye(n_basis), degree, extrapolate=True)(x[outside])
return basis


def basis_to_knots(n_basis: int, degree: int) -> int:
"""Number of internal knots implied by ``n_basis`` basis functions of ``degree``."""
return max(0, n_basis - degree - 1)
Expand Down
2 changes: 2 additions & 0 deletions pretab/core/policy.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
The defaults reproduce each family's historical behaviour (B/M/I-spline, P-spline, and
tensor-product clip out-of-range inputs; natural-cubic and cubic-regression extrapolate;
constant columns pass through everywhere), so leaving ``policy`` unset changes nothing.
A transformer's ``policy`` given as a mapping overrides only the axes it names on top of
those family defaults; a :class:`RepresentationPolicy` instance is used verbatim.
Transformers may narrow specific axes through class-level override attributes without
exposing a new constructor parameter (see :class:`~pretab.core.base.BasePreTabTransformer`).

Expand Down
4 changes: 3 additions & 1 deletion pretab/expansion/spline/b_spline.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,8 @@ def _design_matrix(self, x: np.ndarray, knots: np.ndarray) -> np.ndarray:
for i in range(n_basis):
coef = np.zeros(n_basis)
coef[i] = 1.0
spline = BSpline(knots, coef, self.degree, extrapolate=False)
# In-range values are the same either way; out-of-range values only reach
# this point under an "extrapolate" / "warn" policy (the default clips).
spline = BSpline(knots, coef, self.degree, extrapolate=True)
design[:, i] = spline(x)
return np.nan_to_num(design, nan=0.0)
8 changes: 6 additions & 2 deletions pretab/expansion/spline/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,8 +98,12 @@ class BaseSplineTransformer(BasePreTabTransformer):
Overrides this family's default ``out_of_range`` behavior (unconditional
clipping to the fitted knot range). Pass e.g.
``RepresentationPolicy(out_of_range="error")`` to raise instead, or
``"warn"`` / ``"extrapolate"`` for the other supported reactions. Leaving
this at ``None`` preserves the historical clip-on-transform behavior.
``"warn"`` / ``"extrapolate"`` for the other supported reactions, which
evaluate an out-of-range value on the extended boundary polynomial pieces
of the B/M-spline basis (an I-spline stays at 0 below and 1 above the
range). A mapping overrides only the axes it names, so ``{"constant": "warn"}``
keeps the clipping. Leaving this at ``None`` preserves the historical
clip-on-transform behavior.

Attributes
----------
Expand Down
4 changes: 3 additions & 1 deletion pretab/expansion/spline/m_spline.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,9 @@ def _mspline_basis(self, x: np.ndarray, knots: np.ndarray, basis_idx: int) -> np
n_coef = len(knots) - self.degree - 1
coef = np.zeros(n_coef)
coef[basis_idx] = 1.0
spline = BSpline(knots, coef, self.degree, extrapolate=False)
# In-range values are the same either way; out-of-range values only reach
# this point under an "extrapolate" / "warn" policy (the default clips).
spline = BSpline(knots, coef, self.degree, extrapolate=True)
values = np.nan_to_num(spline(x), nan=0.0)

knot_span = knots[basis_idx + self.degree + 1] - knots[basis_idx]
Expand Down
10 changes: 7 additions & 3 deletions pretab/expansion/spline/multivariate/tensor_product.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
from sklearn.base import BaseEstimator, TransformerMixin
from sklearn.utils.validation import check_is_fitted

from ....core.knots import bspline_basis
from ....core.knots import bspline_basis, extrapolate_bspline_rows
from ....core.parameters import UNSET
from ....core.policy import RepresentationPolicy, resolve_out_of_range
from ....exceptions import InvalidParamError
Expand Down Expand Up @@ -74,8 +74,11 @@ class TensorProductSplineTransformer(SplineBasisMixin, TransformerMixin, BaseEst
policy : RepresentationPolicy, dict, or None, default=None
Overrides this family's default ``out_of_range`` behavior (unconditional
clipping to each marginal dimension's fitted knot range). Pass e.g.
``RepresentationPolicy(out_of_range="error")`` to raise instead. Leaving
this at ``None`` preserves the historical clip-on-transform behavior.
``RepresentationPolicy(out_of_range="error")`` to raise instead, or
``"warn"`` / ``"extrapolate"`` to evaluate an out-of-range value on the
extended boundary polynomial pieces. A mapping overrides only the axes it
names, so ``{"constant": "warn"}`` keeps the clipping. Leaving this at
``None`` preserves the historical clip-on-transform behavior.

Attributes
----------
Expand Down Expand Up @@ -165,6 +168,7 @@ def _basis_matrix(self, x, knots):
B = np.zeros((len(x), n_basis))
for i in range(n_basis):
B[:, i] = bspline_basis(x, knots, self.degree, i)
B = extrapolate_bspline_rows(B, x, knots, self.degree)
if self.include_bias:
B = np.hstack([np.ones((len(x), 1)), B])
return B
Expand Down
10 changes: 7 additions & 3 deletions pretab/expansion/spline/p_spline.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
from sklearn.base import BaseEstimator, TransformerMixin
from sklearn.utils.validation import check_is_fitted

from ...core.knots import bspline_basis
from ...core.knots import bspline_basis, extrapolate_bspline_rows
from ...core.parameters import UNSET
from ...core.policy import RepresentationPolicy, resolve_out_of_range
from ...exceptions import InvalidParamError
Expand Down Expand Up @@ -71,8 +71,11 @@ class PSplineTransformer(SplineBasisMixin, TransformerMixin, BaseEstimator):
policy : RepresentationPolicy, dict, or None, default=None
Overrides this family's default ``out_of_range`` behavior (unconditional
clipping to the fitted knot range). Pass e.g.
``RepresentationPolicy(out_of_range="error")`` to raise instead. Leaving
this at ``None`` preserves the historical clip-on-transform behavior.
``RepresentationPolicy(out_of_range="error")`` to raise instead, or
``"warn"`` / ``"extrapolate"`` to evaluate an out-of-range value on the
extended boundary polynomial pieces. A mapping overrides only the axes it
names, so ``{"constant": "warn"}`` keeps the clipping. Leaving this at
``None`` preserves the historical clip-on-transform behavior.

Attributes
----------
Expand Down Expand Up @@ -210,6 +213,7 @@ def transform(self, X):
basis = np.zeros((len(x), nb))
for j in range(nb):
basis[:, j] = bspline_basis(x, knots, self.degree, j)
basis = extrapolate_bspline_rows(basis, x, knots, self.degree)
if self.include_bias:
basis = np.hstack([np.ones((len(x), 1)), basis])
all_basis.append(basis)
Expand Down
62 changes: 62 additions & 0 deletions tests/core/test_policy.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
this pass.
"""

import warnings

import numpy as np
import pytest

Expand Down Expand Up @@ -125,3 +127,63 @@ def test_policy_dict_is_accepted():
transformer = BSplineTransformer(output_dim=8, policy={"out_of_range": "error"}).fit(X)
with pytest.raises(PretabDataError):
transformer.transform(np.array([[20.0]]))


# --- partial overrides and the "extrapolate" / "warn" reactions ---------------------

_CLIP_FAMILIES = [
(BSplineTransformer, {"output_dim": 6}),
(MSplineTransformer, {"output_dim": 6}),
(ISplineTransformer, {"output_dim": 6}),
(PSplineTransformer, {}),
]


@pytest.mark.parametrize(("cls", "kwargs"), _CLIP_FAMILIES)
def test_partial_policy_mapping_keeps_the_family_out_of_range_default(cls, kwargs):
"""A mapping naming only "constant" used to reset out_of_range to the dataclass
default "extrapolate", silently turning the clip families' out-of-range rows to zero."""
X = np.linspace(0, 1, 50).reshape(-1, 1)
probe = np.array([[1.05], [-0.05]])
default = cls(**kwargs).fit(X).transform(probe)
partial = cls(policy={"constant": "warn"}, **kwargs).fit(X).transform(probe)
np.testing.assert_array_equal(partial, default)


def test_tensorproduct_partial_policy_mapping_keeps_clipping():
X = np.random.default_rng(0).uniform(0, 1, (200, 2))
probe = np.array([[1.05, 0.5]])
default = TensorProductSplineTransformer().fit(X).transform(probe)
partial = TensorProductSplineTransformer(policy={"constant": "warn"}).fit(X).transform(probe)
np.testing.assert_array_equal(partial, default)


def test_policy_mapping_with_an_unknown_axis_is_rejected():
from pretab.exceptions import InvalidParamError

with pytest.raises(InvalidParamError):
BSplineTransformer(policy={"missing": "error"}).fit(np.linspace(0, 1, 50).reshape(-1, 1)).transform([[0.5]])


@pytest.mark.parametrize("cls", [BSplineTransformer, PSplineTransformer])
@pytest.mark.parametrize("reaction", ["extrapolate", "warn"])
def test_extrapolated_bspline_rows_are_not_zeroed(cls, reaction):
"""Out-of-range rows were all zero under "extrapolate" / "warn": the basis is zero
outside the knot span. They now extend the boundary pieces, a partition of unity."""
X = np.linspace(0, 1, 50).reshape(-1, 1)
transformer = cls(policy=RepresentationPolicy(out_of_range=reaction)).fit(X)
with warnings.catch_warnings():
warnings.simplefilter("ignore", DataWarning)
out = transformer.transform(np.array([[1.05], [-0.05], [0.5]]))
np.testing.assert_allclose(out.sum(axis=1), 1.0)
clipped = cls().fit(X).transform(np.array([[0.5]]))
np.testing.assert_array_equal(out[2], clipped[0]) # in-range rows are unchanged


def test_extrapolated_mspline_and_tensor_rows_are_not_zeroed():
policy = RepresentationPolicy(out_of_range="extrapolate")
X = np.linspace(0, 1, 50).reshape(-1, 1)
assert (MSplineTransformer(output_dim=6, policy=policy).fit(X).transform([[1.05]]).sum() > 0).all()
X2 = np.random.default_rng(0).uniform(0, 1, (200, 2))
tensor = TensorProductSplineTransformer(policy=policy).fit(X2)
np.testing.assert_allclose(tensor.transform([[1.05, 0.5]]).sum(), 1.0)
Loading