Skip to content

fix(splines): drop absolute clipping thresholds from the M-spline basis - #107

Open
ChrisW09 wants to merge 1 commit into
mainfrom
fix/63-mspline-scale-free-clipping
Open

ChrisW09 wants to merge 1 commit into
mainfrom
fix/63-mspline-scale-free-clipping

Conversation

@ChrisW09

@ChrisW09 ChrisW09 commented Oct 16, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #63.

MSplineTransformer clipped 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 through Preprocessor(numerical_method="mspline") with quantile knots on a skewed feature.

Root cause

MSplineTransformer._mspline_basis scales each B-spline by (degree + 1) / knot_span, so its values scale like 1 / span in the feature's units. They were then compared against absolute constants: a basis function with knot_span <= 1e-6 was returned as zeros, values were clipped to 1e6, and _design_matrix clipped 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 below 1e-6, every column is zero.

Fix

  • MSplineTransformer._mspline_basis no longer clips the values. A basis function is treated as degenerate, and returned as zeros, only when its span is at most np.finfo(float).eps times the knot range, which happens only for a repeated knot.
  • MSplineTransformer._design_matrix no longer clips the design to ±1e3 or replaces ±inf. It keeps np.nan_to_num(design, nan=0.0), which clears the NaN that BSpline(extrapolate=False) returns outside the knot span.

Each basis function now integrates to one at any feature scale, and rescaling x by c rescales the basis by 1 / c.

Behaviour changes

  • M-spline values are no longer capped at 1000. In the reproduction below, the peak at scale 0.01 is 2001 instead of 1000, and the 234 Preprocessor values that were clipped at exactly 1000 keep their actual value.
  • A basis function with a positive span of at most 1e-6 (in the feature's units) is evaluated instead of returned as zeros, so tiny-range features get a non-zero basis.
  • Where no value exceeded 1000 and every span was above 1e-6, the arithmetic is unchanged and so is the output.

Documentation

  • Docstring of MSplineTransformer._mspline_basis: the formula, that the values scale like 1 / span and are left unclipped, and when a basis function is returned as zeros.
  • No other changes were needed. The module and class docstrings and docs/representations/spline_expansions.md already 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 on main (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 transforming X * 1e-4 gives the unit-scale basis times 1e4 (rtol 1e-8). Fails on main, passes here.
  • test_mspline_basis_is_not_clipped_after_the_preprocessor_scaling: the issue's Preprocessor case (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 on main, passes here.

Reproduction

Output of the script from #63 on this branch:

scale=1: integrals=[1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0]  max=20.01
scale=0.01: integrals=[1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0]  max=2001
scale=0.001: integrals=[1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0]  max=2.001e+04
scale=1e-07: integrals=[1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0]  max=2.001e+08
Preprocessor: values clipped at exactly 1000: 0

Checks

  • just test: 1444 passed, 59 skipped, 7 xfailed; coverage 93.64% (gate 90%).
  • Minimum dependencies (CI job: Python 3.10, numpy 1.24.4, pandas 2.0.3, scipy 1.10.1, scikit-learn 1.6.0): 1431 passed, 72 skipped, 7 xfailed; scripts/quickstart.py passes.
  • 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).
  • Import direction between subpackages is unchanged.

Merge notes

This change was previously part of #75, which is closed in favour of one pull request per issue.

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 branch has not been deployed

No deployments
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.

MSplineTransformer: absolute clipping thresholds make the basis depend on feature scale

1 participant