diff --git a/pretab/transformers/splines/base_spline.py b/pretab/transformers/splines/base_spline.py index 256de14..104a268 100644 --- a/pretab/transformers/splines/base_spline.py +++ b/pretab/transformers/splines/base_spline.py @@ -123,7 +123,7 @@ class BaseSplineTransformer(BasePreTabTransformer): >>> from pretab.transformers import BSplineTransformer >>> X = np.linspace(0, 1, 50).reshape(-1, 1) >>> BSplineTransformer(output_dim=8).fit_transform(X).shape - (50, 9) + (50, 8) """ def __init__( diff --git a/pretab/transformers/splines/bspline.py b/pretab/transformers/splines/bspline.py index 72838a7..0adcf90 100644 --- a/pretab/transformers/splines/bspline.py +++ b/pretab/transformers/splines/bspline.py @@ -24,7 +24,15 @@ class BSplineTransformer(BaseSplineTransformer): is expanded column by column and the results are stacked horizontally. See :class:`~pretab.transformers.splines.base_spline.BaseSplineTransformer` - for the full parameter description. ``include_bias`` defaults to True here. + for the full parameter description. + + .. note:: + ``include_bias`` defaults to ``False``. A B-spline basis over a clamped + knot vector is a partition of unity -- every row sums to 1 -- so an + extra intercept column is an exact linear combination of the basis and + makes the design matrix singular. Set it to ``True`` only if a + downstream model needs the explicit column and tolerates the + collinearity. Examples -------- @@ -32,14 +40,14 @@ class BSplineTransformer(BaseSplineTransformer): >>> from pretab.transformers import BSplineTransformer >>> X = np.linspace(0, 1, 50).reshape(-1, 1) >>> BSplineTransformer(output_dim=8).fit_transform(X).shape - (50, 9) + (50, 8) """ def __init__( self, output_dim=UNSET, degree: int = 3, - include_bias: bool = True, + include_bias: bool = False, knot_locations: np.ndarray | None = None, target_aware: bool = False, placement_strategy: str = "quantile", diff --git a/tests/test_adaptive_output_dim.py b/tests/test_adaptive_output_dim.py index 745beb0..a70fa47 100644 --- a/tests/test_adaptive_output_dim.py +++ b/tests/test_adaptive_output_dim.py @@ -56,8 +56,7 @@ "tprs": OUTPUT_DIM, "mspline": OUTPUT_DIM, "ispline": OUTPUT_DIM, - # B-spline defaults to include_bias=True -> output_dim + 1 - "bspline": OUTPUT_DIM + 1, + "bspline": OUTPUT_DIM, } # Families that honor the adaptive window when driven through the Preprocessor. diff --git a/tests/test_spline_expansions.py b/tests/test_spline_expansions.py index 40e50e2..e350208 100644 --- a/tests/test_spline_expansions.py +++ b/tests/test_spline_expansions.py @@ -132,3 +132,46 @@ def test_spline_penalty_matrix_symmetric(data): P = transformer.get_penalty_matrix() assert P.shape[0] == P.shape[1] assert np.allclose(P, P.T, atol=1e-9) + + +# --------------------------------------------------------------------------- # +# A partition-of-unity basis must not carry a redundant intercept column. +# +# ``BSplineTransformer`` used to default to ``include_bias=True``. A B-spline +# basis over a clamped knot vector sums to 1 on every row, so the prepended +# column of ones is an exact linear combination of the rest and the design +# matrix is singular (condition number ~1e15). +# --------------------------------------------------------------------------- # +def test_bspline_default_design_is_full_rank(): + X = np.linspace(0, 1, 200).reshape(-1, 1) + + design = BSplineTransformer(output_dim=8).fit_transform(X) + + assert design.shape[1] == 8 + assert np.linalg.matrix_rank(design) == design.shape[1] + assert np.linalg.cond(design) < 1e6 + + +def test_bspline_basis_is_a_partition_of_unity(): + # This is *why* the bias column is redundant; pin it so the reasoning holds. + X = np.linspace(0, 1, 200).reshape(-1, 1) + + design = BSplineTransformer(output_dim=8).fit_transform(X) + + np.testing.assert_allclose(design.sum(axis=1), 1.0) + + +def test_bspline_include_bias_still_available_opt_in(): + X = np.linspace(0, 1, 200).reshape(-1, 1) + + design = BSplineTransformer(output_dim=8, include_bias=True).fit_transform(X) + + assert design.shape[1] == 9 + np.testing.assert_allclose(design[:, 0], 1.0) + + +@pytest.mark.parametrize("cls", [BSplineTransformer, MSplineTransformer, ISplineTransformer]) +def test_bmi_splines_default_to_exactly_output_dim(cls): + X = np.linspace(0, 1, 200).reshape(-1, 1) + + assert cls(output_dim=7).fit_transform(X).shape[1] == 7