Skip to content

fix(splines): evaluate the I-spline basis in closed form - #98

Open
ChrisW09 wants to merge 1 commit into
mainfrom
fix/53-ispline-closed-form
Open

ChrisW09 wants to merge 1 commit into
mainfrom
fix/53-ispline-closed-form

Conversation

@ChrisW09

@ChrisW09 ChrisW09 commented Oct 16, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #53.

ISplineTransformer returned inaccurate and sometimes identically zero basis columns whenever an M-spline's support was narrow compared to the knot range. This happens on skewed data under the default quantile placement and with a larger output_dim, also through Preprocessor, and without a warning.

Root cause

ISplineTransformer._ispline_basis integrated each M-spline with the trapezoid rule on a fixed 200-point grid over the whole knot range, then interpolated the cumulative integral linearly with np.interp. A basis function whose support spans only a few grid cells ramped up over whole cells instead of over its support. One whose support falls between two grid points integrated to 0, and since normalization only ran if max_integral > 1e-10, its column stayed all zeros. Such rows also break the ordering I_0 ≥ I_1 ≥ … ≥ I_{K-1} that every I-spline row satisfies.

Fix

  • New ISplineTransformer._ispline_columns evaluates every I-spline column in closed form. The antiderivative of the B-spline basis, BSpline(knots, np.eye(n_coef), degree).antiderivative(), is itself a B-spline of degree p + 1. Each column is that antiderivative minus its value at the left boundary, divided by the analytic integral (t_{k+p+1} - t_k) / (p + 1).
  • x is clipped to the knot range, so values below and above it are 0 and 1. At the right boundary the value is pinned to the full integral instead of being evaluated, so a repeated boundary knot cannot put the evaluation on a zero-width interval. A basis function with zero-width support (a repeated knot) becomes the step function at that knot, its limit as the support shrinks.
  • _ispline_basis delegates to _ispline_columns, and _design_matrix builds the whole design in one call instead of one call per column.

Behaviour changes

  • I-spline values change wherever the quadrature was inaccurate: narrow knot spans on skewed data or with a larger output_dim. On benign data (normal, output_dim=6) the old values were within about 2e-4 of the exact ones. The golden regression baselines pass unchanged.
  • A basis function with zero-width support, which on main arises when an interior knot coincides with a boundary knot on tied data, is now the step function at that knot instead of an all-zero column.

Documentation

  • The class docstring ("the integral of an M-spline over the fitted range ... normalized to [0, 1]") and docs/representations/spline_expansions.md (I_k(x) = ∫_{τ_k}^{x} M_k(t) dt) already describe the fixed behaviour; the implementation now matches them exactly, so they are unchanged.
  • The new private _ispline_columns has a docstring describing the closed form, the boundary values and the zero-width case; _ispline_basis refers to it.

Tests

  • test_ispline_matches_exact_integral_on_tight_knots[lognormal(0, 2)-{6,10,30}, normal-{20,50}, lognormal(0, 1)-20, exponential-20]: the transform equals the normalized antiderivative of each B-spline basis function to 1e-10, no column is all zero, and every row is ordered I_0 ≥ I_1 ≥ …. The cases cover the issue's reproduction and every row of its error table. Fails on main (all 7 cases), passes here.
  • test_ispline_reaches_zero_and_one_at_the_range_boundaries: on lognormal(0, 2) data with output_dim=10, every column is exactly 0 at the training minimum and exactly 1 at the training maximum. Fails on main, passes here.

Reproduction

Output of the script from #53 on this branch:

all-zero columns: []
max |pretab - exact| per column: [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]

Checks

  • just test: 1446 passed, 59 skipped, 7 xfailed; coverage 93.54% (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): 1433 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.

ISplineTransformer integrated each M-spline with a fixed 200-point
trapezoid grid over the full knot range, so a basis function whose
support is narrower than one grid step came out identically zero or
ramped up over the wrong interval. This happens on skewed data under
the default quantile placement and with a larger output_dim, and it
also broke the ordering I_0 >= I_1 >= ... >= I_{K-1} of every row.

Each I-spline is now the antiderivative of its B-spline (itself a
B-spline of degree p + 1) divided by its analytic integral
(t_{k+p+1} - t_k) / (p + 1), which is exact for any knot spacing.
Values below / above the knot range stay 0 / 1. A basis function with
zero-width support (a repeated knot) becomes the step function at that
knot, its limit as the support shrinks.

Fixes #53

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.

ISplineTransformer: 200-point trapezoid integration produces wrong and all-zero basis columns

1 participant