Repository navigation
Conversation
MSplineTransformer zeroed every basis function whose knot span was below 1e-6, clipped the values to 1e6 and the whole design to +/-1e3. M-spline values scale like 1 / span, so these absolute limits made the basis depend on the units of the feature: on small-scale features, or with tight knots, the peaks were flattened at 1000 and the integrals fell below one, and on tiny-range features the output became all zero. The clips are removed, so each basis function integrates to one at any feature scale and rescaling x by c rescales the basis by 1 / c. Only a basis function whose support is degenerate relative to the knot range (zero width, possible only for a repeated knot) is returned as zeros. Fixes #63
This was referenced Oct 16, 2026
This branch has not been deployed
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 #63.
MSplineTransformerclipped its basis at absolute thresholds, so the basis depended on the units of the feature. On small-scale features, or with tight knots, the peaks were flattened at 1000 and the basis functions no longer integrated to one; on tiny-range features the whole output was zero. The same happened throughPreprocessor(numerical_method="mspline")with quantile knots on a skewed feature.Root cause
MSplineTransformer._mspline_basisscales each B-spline by(degree + 1) / knot_span, so its values scale like1 / spanin the feature's units. They were then compared against absolute constants: a basis function withknot_span <= 1e-6was returned as zeros, values were clipped to1e6, and_design_matrixclipped the whole design to±1e3. Once a support is narrower than(degree + 1) / 1000, its peak exceeds 1000 and is cut off; once every span is below1e-6, every column is zero.Fix
MSplineTransformer._mspline_basisno longer clips the values. A basis function is treated as degenerate, and returned as zeros, only when its span is at mostnp.finfo(float).epstimes the knot range, which happens only for a repeated knot.MSplineTransformer._design_matrixno longer clips the design to±1e3or replaces±inf. It keepsnp.nan_to_num(design, nan=0.0), which clears the NaN thatBSpline(extrapolate=False)returns outside the knot span.Each basis function now integrates to one at any feature scale, and rescaling
xbycrescales the basis by1 / c.Behaviour changes
Preprocessorvalues that were clipped at exactly 1000 keep their actual value.1e-6(in the feature's units) is evaluated instead of returned as zeros, so tiny-range features get a non-zero basis.1e-6, the arithmetic is unchanged and so is the output.Documentation
MSplineTransformer._mspline_basis: the formula, that the values scale like1 / spanand are left unclipped, and when a basis function is returned as zeros.docs/representations/spline_expansions.mdalready state that each M-spline integrates to one, which now holds at any scale, and no page mentions the removed thresholds.Tests
test_mspline_basis_integrates_to_one_at_any_feature_scale[0.01|0.001|1e-07]: on uniform data scaled by 0.01, 0.001 and 1e-7, every column integrates to 1 over the fitted range (atol 1e-3). Fails onmain(smallest integrals 0.809, 0.145 and 0.0), passes here.test_mspline_basis_integrates_to_one_at_any_feature_scale[1.0]: the unit-scale control. Guard test: passes on both by design.test_mspline_basis_is_scale_equivariant: on lognormal(0, 2) data, fitting and transformingX * 1e-4gives the unit-scale basis times1e4(rtol 1e-8). Fails onmain, passes here.test_mspline_basis_is_not_clipped_after_the_preprocessor_scaling: the issue'sPreprocessorcase (quantile knots on a lognormal(0, 2) feature, default min-max scaling to [-1, 1]). The basis exceeds 1000 and every column integrates to 1 over the scaled range. Fails onmain, passes here.Reproduction
Output of the script from #63 on this branch:
Checks
just test: 1444 passed, 59 skipped, 7 xfailed; coverage 93.64% (gate 90%).scripts/quickstart.pypasses.just docs: strict build succeeds.just check: all pre-commit and pre-push hooks pass (ruff format, ruff lint, prettier, file hygiene, pyright with 0 errors).Merge notes
main.BSpline(...)call in the sameMSplineTransformer._mspline_basistoextrapolate=True. The hunks do not overlap, and git merges them without conflict. With both, an out-of-range value under an"extrapolate"or"warn"policy is scaled like an in-range one and is not capped at 1000 either.np.nan_to_num(design, nan=0.0)that this PR keeps in_design_matrix. It also updatestest_mspline_handles_nan, directly above the tests added here. The PRs for ISplineTransformer: 200-point trapezoid integration produces wrong and all-zero basis columns #53 (fix(splines): evaluate the I-spline basis in closed form #98), Target-aware splines trim selector knots by position, dropping the dominant split, and cap adaptive width at 15 #69 (fix(splines): search the resolved knot window in target-aware placement #109), B/M/I spline knot_locations are sized by output_dim and clipped to the data range instead of used as given #81 (fix(splines): honour explicit knot_locations regardless of output_dim #118) and A 2-D y (multi-output target) crashes the B/M/I splines even when unsupervised, and target-aware methods with unrelated errors #88 (fix(splines): ignore y when unsupervised and reject a multi-output target #123) add tests elsewhere intests/expansion/spline/test_spline_expansions.py. Each of these branches merges with this one without conflict. With the branches for A partial spline policy mapping drops the "clip" default, and "extrapolate"/"warn" give all-zero basis rows #84, B/M/I spline and two-feature thin-plate spline transformers encode a missing value as an all-zero row #87 and ISplineTransformer: 200-point trapezoid integration produces wrong and all-zero basis columns #53 applied on top of this one, the full test suite passes (1499 passed, 59 skipped, 7 xfailed).ISplineTransformer, which computes its own M-spline values and does not useMSplineTransformer. The two fixes are independent.This change was previously part of #75, which is closed in favour of one pull request per issue.