Skip to content

fix(splines): close the final B-spline knot span - #24

Open
ChrisW09 wants to merge 1 commit into
mainfrom
fix/spline-basis-closed-at-range-max
Open

fix(splines): close the final B-spline knot span#24
ChrisW09 wants to merge 1 commit into
mainfrom
fix/spline-basis-closed-at-range-max

Conversation

@ChrisW09

Copy link
Copy Markdown
Collaborator

Fixes #12.

Problem

The degree-0 base of the Cox-de Boor recursion used the half-open span
[k_j, k_{j+1}):

if degree == 0:
    return ((knots[i] <= x) & (x < knots[i + 1])).astype(float)

So x == knots[-1] belongs to no span, every basis function evaluates to zero, and the row
carries no signal. Because the default scaling="minmax" pins the scaler to the training
range, this hit the training argmax and every inference value at or beyond the training
maximum.

PSplineTransformer and TensorProductSplineTransformer were both affected;
BaseSplineTransformer (B/M/I) clips first and was not, so the two families disagreed.

Fix

Close the last knot span of non-zero width. Picking the positive-width span matters and
is the subtle part: a clamped knot vector repeats its boundary knot degree + 1 times, so
the trailing spans are degenerate. Closing len(knots) - 2 — the obvious choice — has no
effect, because the zero denominators further up the recursion cancel its contribution. I
confirmed that empirically before settling on _last_positive_span.

Clip into the fitted range in both transform methods, matching
BaseSplineTransformer.transform, so out-of-range values evaluate on the boundary rather
than falling off the basis.

Consolidate the duplicate. bspline_basis existed as two verbatim copies (pspline.py,
tensor_product.py); it now lives once in pretab.core.knots, with both modules
re-exporting the name so any existing import keeps working. Without this the fix has to be
applied twice and can silently diverge.

Result

Partition of unity across the full range, degrees 1–3, both families:

before  PSpline degree=3: row sums [... 1. 1. 0.]   <- argmax row
after   PSpline degree=3: row sums [... 1. 1. 1.]   allclose(sum, 1) == True

before  out-of-range [-0.5, 0.5, 1.5] sums: [0. 1. 0.]
after   out-of-range [-0.5, 0.5, 1.5] sums: [1. 1. 1.]

Through the Preprocessor, all-zero training rows go from 1 to 0 for both pspline and
tensorspline.

Tests

Seven added across tests/test_pspline_transformer.py and
tests/test_tensorproduct_transformer.py: partition-of-unity parametrised over degrees 1–3,
an explicit "max row is not all zero" check, out-of-range clipping (asserting the clipped
value equals the boundary value), and a multivariate guard that the tensor product still
multiplies its marginals.

Full suite: 491 passed, 9 xfailed. ruff check clean on changed files; pyright back to its
pre-existing 71 errors — my first pass added one (the new return annotation exposed a float
path when both denominators vanish), fixed by returning a zero array instead of 0.0.

🤖 Generated with Claude Code

The degree-0 base of the Cox-de Boor recursion used the half-open span
``[k_j, k_{j+1})``, so the largest value in the data belonged to no span and
every basis function evaluated to zero there. The training argmax encoded as
an all-zero row, and because the default minmax scaler pins the range, so did
every value at or beyond the training maximum at inference time.

Treat the last span of non-zero width as closed. Picking the last *positive
width* span matters: a clamped knot vector repeats its boundary knot
``degree + 1`` times, so the trailing spans are degenerate and closing one of
those is cancelled by the zero denominators further up the recursion.

Also clip inputs into the fitted range in both ``transform`` methods, matching
``BaseSplineTransformer.transform``, so out-of-range values evaluate on the
boundary instead of falling off the basis entirely.

The two verbatim copies of ``bspline_basis`` (in ``pspline`` and
``tensor_product``) are consolidated into ``pretab.core.knots`` so the fix
cannot diverge again; both modules re-export the name for compatibility.

Partition of unity now holds across the full range for degrees 1-3 in both
families, where previously the final row summed to 0.

Closes #12

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(splines): p-spline basis is all-zero at and beyond the range max

1 participant