From 97b0ab1f5efeadf7f054ce3e109c23ea915f5635 Mon Sep 17 00:00:00 2001 From: ChrisW09 <50968720+ChrisW09@users.noreply.github.com> Date: Mon, 27 Jul 2026 22:39:38 +0200 Subject: [PATCH] fix(splines): stop adding a collinear bias column to the B-spline basis ``BSplineTransformer`` defaulted to ``include_bias=True`` and was the only spline family that did. A B-spline basis over a clamped knot vector is a partition of unity -- every row sums to exactly 1 -- so the prepended intercept column is an exact linear combination of the remaining columns and the design matrix is singular: include_bias=True (200, 9) rank 8 cond 6.30e+15 include_bias=False (200, 8) rank 8 cond 1.79e+01 ``Preprocessor(numerical_method="bspline")`` inherited that default, so every user of the method got a rank-deficient design: an unidentifiable intercept for OLS, and a wasted direction for a regularized fit. Default ``include_bias`` to False, matching MSpline, ISpline and the shared base class. ``include_bias=True`` remains available for callers that want the explicit column. The same change resolves two further symptoms of the same cause. bspline was the only method whose per-feature width differed from ``output_dim`` (8 for output_dim=7, where every other family gave 7), and the only one that could breach the adaptive window (10 against max_output_dim=9). Both now match. ``test_fixed_width_matches_expected[bspline]`` encoded the old ``output_dim + 1`` width and is updated; the two docstring examples showing ``(50, 9)`` are too. Closes #33 Co-Authored-By: Claude Opus 5 (1M context) --- pretab/transformers/splines/base_spline.py | 2 +- pretab/transformers/splines/bspline.py | 14 +++++-- tests/test_adaptive_output_dim.py | 3 +- tests/test_spline_expansions.py | 43 ++++++++++++++++++++++ 4 files changed, 56 insertions(+), 6 deletions(-) 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