fix(splines): close the final B-spline knot span - #24
Open
ChrisW09 wants to merge 1 commit into
Open
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #12.
Problem
The degree-0 base of the Cox-de Boor recursion used the half-open span
[k_j, k_{j+1}):So
x == knots[-1]belongs to no span, every basis function evaluates to zero, and the rowcarries no signal. Because the default
scaling="minmax"pins the scaler to the trainingrange, this hit the training argmax and every inference value at or beyond the training
maximum.
PSplineTransformerandTensorProductSplineTransformerwere 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 + 1times, sothe trailing spans are degenerate. Closing
len(knots) - 2— the obvious choice — has noeffect, 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
transformmethods, matchingBaseSplineTransformer.transform, so out-of-range values evaluate on the boundary ratherthan falling off the basis.
Consolidate the duplicate.
bspline_basisexisted as two verbatim copies (pspline.py,tensor_product.py); it now lives once inpretab.core.knots, with both modulesre-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:
Through the
Preprocessor, all-zero training rows go from 1 to 0 for bothpsplineandtensorspline.Tests
Seven added across
tests/test_pspline_transformer.pyandtests/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 checkclean on changed files; pyright back to itspre-existing 71 errors — my first pass added one (the new return annotation exposed a
floatpath when both denominators vanish), fixed by returning a zero array instead of
0.0.🤖 Generated with Claude Code