From ccf81cdcd2d07ae619bd10bfb47df8b3f020cbe4 Mon Sep 17 00:00:00 2001 From: ChrisW09 <50968720+ChrisW09@users.noreply.github.com> Date: Fri, 16 Oct 2026 14:20:43 +0200 Subject: [PATCH] fix(splines): keep family policy defaults and extrapolate out-of-range rows A policy mapping such as {"constant": "warn"} was expanded with the dataclass defaults, so it reset out_of_range to "extrapolate" and dropped the clip default of the B/M/I-spline, P-spline and tensor-product families. Under "extrapolate" / "warn" those bases then evaluated to zero outside the knot span (BSpline(extrapolate=False) plus nan_to_num, and the Cox-de Boor recursion), turning every out-of-range row into an all-zero basis row. A mapping now overrides only the axes it names on top of the family defaults (an unknown axis raises a typed error; a RepresentationPolicy instance is still used verbatim), and out-of-range rows are evaluated by extending the boundary polynomial pieces: BSpline(extrapolate=True) for the B/M-splines, and for the out-of-range rows of the P-spline / tensor-product basis. In-range values are unchanged. The policy parameter docs of the clipping families and the edge-case notes in choosing_a_method.md describe both behaviours. Fixes #84 --- docs/representations/choosing_a_method.md | 6 +- pretab/core/base.py | 23 ++++--- pretab/core/knots.py | 18 ++++++ pretab/core/policy.py | 2 + pretab/expansion/spline/b_spline.py | 4 +- pretab/expansion/spline/base.py | 8 ++- pretab/expansion/spline/m_spline.py | 4 +- .../spline/multivariate/tensor_product.py | 10 ++- pretab/expansion/spline/p_spline.py | 10 ++- tests/core/test_policy.py | 62 +++++++++++++++++++ 10 files changed, 127 insertions(+), 20 deletions(-) diff --git a/docs/representations/choosing_a_method.md b/docs/representations/choosing_a_method.md index 2e457d5..ac4e118 100644 --- a/docs/representations/choosing_a_method.md +++ b/docs/representations/choosing_a_method.md @@ -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 diff --git a/pretab/core/base.py b/pretab/core/base.py index 284a0da..aa902c6 100644 --- a/pretab/core/base.py +++ b/pretab/core/base.py @@ -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.""" diff --git a/pretab/core/knots.py b/pretab/core/knots.py index 3a5b7b4..8d32fd0 100644 --- a/pretab/core/knots.py +++ b/pretab/core/knots.py @@ -19,6 +19,7 @@ __all__ = [ "basis_to_knots", "bspline_basis", + "extrapolate_bspline_rows", "generate_internal_knots", "quantile_knots", "select_knots", @@ -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) diff --git a/pretab/core/policy.py b/pretab/core/policy.py index 10a27ec..9c49484 100644 --- a/pretab/core/policy.py +++ b/pretab/core/policy.py @@ -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`). diff --git a/pretab/expansion/spline/b_spline.py b/pretab/expansion/spline/b_spline.py index 293471c..44abe1f 100644 --- a/pretab/expansion/spline/b_spline.py +++ b/pretab/expansion/spline/b_spline.py @@ -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) diff --git a/pretab/expansion/spline/base.py b/pretab/expansion/spline/base.py index ab365bf..149537d 100644 --- a/pretab/expansion/spline/base.py +++ b/pretab/expansion/spline/base.py @@ -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 ---------- diff --git a/pretab/expansion/spline/m_spline.py b/pretab/expansion/spline/m_spline.py index f98682e..2097361 100644 --- a/pretab/expansion/spline/m_spline.py +++ b/pretab/expansion/spline/m_spline.py @@ -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] diff --git a/pretab/expansion/spline/multivariate/tensor_product.py b/pretab/expansion/spline/multivariate/tensor_product.py index 2be256b..839a570 100644 --- a/pretab/expansion/spline/multivariate/tensor_product.py +++ b/pretab/expansion/spline/multivariate/tensor_product.py @@ -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 @@ -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 ---------- @@ -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 diff --git a/pretab/expansion/spline/p_spline.py b/pretab/expansion/spline/p_spline.py index 8328484..0d71548 100644 --- a/pretab/expansion/spline/p_spline.py +++ b/pretab/expansion/spline/p_spline.py @@ -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 @@ -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 ---------- @@ -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) diff --git a/tests/core/test_policy.py b/tests/core/test_policy.py index 0b99b20..7d4abbe 100644 --- a/tests/core/test_policy.py +++ b/tests/core/test_policy.py @@ -14,6 +14,8 @@ this pass. """ +import warnings + import numpy as np import pytest @@ -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)