Skip to content

fix(splines): honour explicit knot_locations regardless of output_dim - #118

Open
ChrisW09 wants to merge 1 commit into
mainfrom
fix/81-explicit-knot-locations
Open

ChrisW09 wants to merge 1 commit into
mainfrom
fix/81-explicit-knot-locations

Conversation

@ChrisW09

@ChrisW09 ChrisW09 commented Oct 16, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #81.

BSplineTransformer, MSplineTransformer and ISplineTransformer document that knot_locations overrides output_dim, but they rejected any knot count other than output_dim - degree - 1, trimmed or padded the knots in adaptive mode, and silently clipped knots on or outside the data range onto the boundary, which gave zero-width basis functions (all-zero columns). Users who need a fixed knot grid could not use these transformers for it.

Root cause

BaseSplineTransformer._column_knots ran explicit knots through the output_dim machinery of automatic placement. It checked len(knot_locations) against output_dim - degree - 1 (raising IncompatibleParamsError when adaptive=False), then passed the knots to _adjust_internal_knots, which clips them to [x_min, x_max], de-duplicates them, tops them up with quantile / uniform knots below the minimum count of the output_dim window and down-samples them with select_knots above its maximum.

Fix

  • BaseSplineTransformer._column_knots handles explicit knots first, before the output_dim window is resolved, and returns the boundary knots around _explicit_knots(x_min, x_max). The selector and automatic paths are unchanged (still _adjust_internal_knots, then the trailing np.clip / np.unique).
  • New BaseSplineTransformer._explicit_knots merges repeated knots, keeps those strictly inside the feature's fitted range (x_min, x_max) and drops the others with a DataWarning that lists them. A feature gets len(kept) + degree + 1 basis functions.
  • BaseSplineTransformer.fit skips the output_dim range checks when knot_locations is given and calls the new _validate_knot_locations, which raises InvalidParamError unless knot_locations is a 1-D sequence of finite numbers.
  • The now unused IncompatibleParamsError import is removed from pretab/expansion/spline/base.py.

Behaviour changes

  • Any number of explicit knots is accepted. The IncompatibleParamsError "knot_locations length must match output_dim - degree - 1 when adaptive=False" is no longer raised.
  • With explicit knots, output_dim, min_output_dim and max_output_dim are ignored, including their range checks (an output_dim below degree + 1 or above 50 no longer raises), and adaptive mode neither trims nor pads the knots.
  • Knots on or outside a feature's fitted range are dropped for that feature with a DataWarning instead of being clipped onto the boundary. With several features, a feature whose range excludes some knots therefore gets fewer columns than the others; knot_locations=[-5, 15] on [0, 10] gives no interior knot and degree + 1 columns instead of two boundary knots and two all-zero columns.
  • Repeated knots are merged without refilling: knot_locations=[5, 5] on [0, 10] gives the interior knot [5.] (5 columns) instead of [3.33, 6.67] (6 columns).
  • knot_locations that is not 1-D, non-numeric or non-finite raises InvalidParamError at fit.
  • Automatic and target-aware placement are unchanged; the golden regression baselines pass unchanged.

Documentation

  • BaseSplineTransformer docstring, knot_locations parameter (shared by the B-, M- and I-spline transformers): overrides output_dim and the adaptive bounds, a feature gets len(knot_locations) + degree + 1 basis functions, repeated knots are merged, and knots on or outside a feature's fitted range are dropped for that feature with a DataWarning.
  • docs/representations/spline_expansions.md, "Constructor highlights": the same contract in one sentence.
  • Docstrings for the new private helpers _explicit_knots and _validate_knot_locations.

Tests

  • test_knot_locations_set_the_width_regardless_of_output_dim[{1,4,12} knots-{B,M,I}SplineTransformer]: knots_ holds exactly the given interior knots and the output has len(knots) + 4 columns with the default output_dim. Fails on main (raises), passes here.
  • test_knot_locations_are_kept_in_adaptive_mode[not-trimmed]: [2, 4, 6, 8] with min_output_dim=5, max_output_dim=6 is kept as given. Fails on main ([2, 8]), passes here.
  • test_knot_locations_are_kept_in_adaptive_mode[not-padded]: [5] with min_output_dim=7, max_output_dim=8 is kept as given. Fails on main ([2.5, 5, 7.5]), passes here.
  • test_knot_locations_outside_the_range_are_dropped_with_a_warning: [-5, 3, 3, 15] warns about [-5.0, 15.0] and leaves the single interior knot [3.0]. Fails on main, passes here.
  • test_knot_locations_on_the_boundary_are_dropped_instead_of_giving_a_dead_column: [0, 5] warns about [0.0], leaves [5.0] and no column is all-zero on the training data. Fails on main, passes here.
  • test_invalid_knot_locations_raise_a_typed_error[2-D, non-numeric, NaN]: InvalidParamError naming knot_locations. Fails on main (IncompatibleParamsError from the length check), passes here.

Reproduction

Output of the script from #81 on this branch, extended with the M- and I-spline cases and the [5, 5] and [0, 5] cases from the issue text, with the warnings printed:

4 knots (BSplineTransformer) -> interior knots [2. 4. 6. 8.] | width 8
4 knots (MSplineTransformer) -> interior knots [2. 4. 6. 8.] | width 8
4 knots (ISplineTransformer) -> interior knots [2. 4. 6. 8.] | width 8
4 knots, adaptive -> interior knots [2. 4. 6. 8.]
1 knot, adaptive  -> interior knots [5.]
knots [-5, 15] -> interior knots [] | all-zero columns on the training data: []
    DataWarning: knot_locations [-5.0, 15.0] lie outside the open feature range (0.0, 10.0) and were dropped: a knot on or beyond the boundary gives a basis function with zero-width support.
knots [5, 5] -> interior knots [5.] | all-zero columns on the training data: []
knots [0, 5] -> interior knots [5.] | all-zero columns on the training data: []
    DataWarning: knot_locations [0.0] lie outside the open feature range (0.0, 10.0) and were dropped: a knot on or beyond the boundary gives a basis function with zero-width support.

Checks

  • just test: 1454 passed, 59 skipped, 7 xfailed; coverage 93.82% (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): 1441 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 #86, which is closed in favour of one pull request per issue.

BaseSplineTransformer documented that output_dim is ignored when
knot_locations is given, but it still checked the knot count against
output_dim - degree - 1 (raising for any other count), trimmed the knots
to the adaptive window, and clipped out-of-range knots onto the boundary,
which silently replaced them by degenerate boundary knots.

Explicit knots now fix the width (len(knot_locations) + degree + 1 basis
functions per feature) and bypass output_dim and the adaptive bounds.
Repeated knots are merged, knots on or outside a feature's fitted range
are dropped with a DataWarning, and knot_locations must be a 1-D sequence
of finite numbers (InvalidParamError otherwise).

Fixes #81

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.

B/M/I spline knot_locations are sized by output_dim and clipped to the data range instead of used as given

1 participant