Skip to content

fix(splines): search the resolved knot window in target-aware placement - #109

Open
ChrisW09 wants to merge 1 commit into
mainfrom
fix/69-target-aware-knot-window
Open

ChrisW09 wants to merge 1 commit into
mainfrom
fix/69-target-aware-knot-window

Conversation

@ChrisW09

@ChrisW09 ChrisW09 commented Oct 16, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #69.

With target_aware=True, the B/M/I, cubic-regression and natural-cubic splines cut the selector's knots down to the needed count by position. The knot where the target changes was routinely discarded: at output_dim=6, a CART B-spline missed a single step in 199 of 200 simulated datasets. Adaptive widths were also capped at 15 basis functions (14 for cubic regression, 12 for natural cubic) whatever max_output_dim was. This affects Preprocessor(numerical_method="bspline"), which the regression presets select.

Root cause

SplinePlacementAdapter.get_knot_locations always searched the fixed default window of 3-15 basis functions, independent of the transformer's resolved width. BaseSplineTransformer._adjust_internal_knots and SplineBasisMixin._clamp_interior_knots then reduced the result with select_knots, which keeps evenly spaced indices of the sorted knot list and ignores the selector's importance ranking. The fixed window also capped adaptive widths, and a window lying entirely above 15 collapsed to min_output_dim.

Fix

  • SplinePlacementAdapter.get_knot_locations gains keyword-only min_knots / max_knots, which override the adapter's default window for that call.
  • BaseSplineTransformer._column_knots passes the interior-knot window resolved from output_dim or from [min_output_dim, max_output_dim] on the selector path. When the adaptive distinct-value cap pushes max_knots below min_knots, min_knots is lowered to max_knots first.
  • SplineBasisMixin._place_interior_knots, used by the cubic-regression and natural-cubic splines, resolves the fixed or adaptive window before calling the selector and passes it.
  • The selector's own importance-based trim now decides which splits survive. The later select_knots / top-up steps stay in place for selectors that return too few or too many knots.

The fix in #75 also changed the top-up in SplineBasisMixin._clamp_interior_knots to call supplement_interior_knots, so that every selector knot is kept. That helper is introduced by the PR for #56 (#101) and is not on main, so this PR keeps main's top-up there. See Merge notes.

Behaviour changes

  • Target-aware B/M/I, cubic-regression and natural-cubic knots change: the selector's most informative splits are kept instead of evenly spaced ones by position. This includes Preprocessor(numerical_method="bspline") with target-aware placement.
  • Adaptive target-aware spline widths are no longer capped at 15, 14 or 12 and can reach max_output_dim. A window lying above 15 no longer collapses to min_output_dim. On the issue's sine data, [5, 50] now gives 34 for the B/M/I splines, 33 for cubic regression and 31 for natural cubic.
  • docs/tutorials/adaptive_resolution.md now prints selected width 20 for both signals instead of 15.
  • SplinePlacementAdapter.get_knot_locations takes two new optional keyword-only arguments. Calls without them use the 3-15 default window as before.
  • Unsupervised placement, explicit knot_locations and the feature maps are unchanged, and the golden regression baselines pass unchanged.

Documentation

  • SplinePlacementAdapter: class docstring and module comment say that the 3-15 window is only the standalone default. get_knot_locations gets a NumPy docstring with the new parameters.
  • adaptive parameter of BaseSplineTransformer (B-, M- and I-spline), CubicRegressionSplineTransformer and NaturalCubicSplineTransformer: a target-aware selector searches exactly this window (or the output_dim width) and keeps its most informative splits.
  • docs/tutorials/adaptive_resolution.md: the printed widths change from 15 / 15 to 20 / 20, and the explanation now says that both signals reach the upper bound.

Tests

  • test_target_aware_bmi_spline_keeps_the_dominant_split[{5,6}-{B,M,I}SplineTransformer]: the interior-knot count is exact and one knot lies within 0.1 of the step at 5.0. Fails on main, passes here.
  • test_target_aware_cubic_families_keep_the_dominant_split[{CubicRegression,NaturalCubic}SplineTransformer]: at output_dim=5, one knot lies within 0.1 of the step. Fails on main, passes here.
  • test_adaptive_spline_width_is_not_capped_at_the_legacy_window[{5-40,20-50}-{BSpline,CubicRegressionSpline,NaturalCubicSpline}Transformer]: the width lies in (max(15, low), high]. Fails on main, passes here; main gives 15, 14 or 12, and 20 for [20, 50].
  • test_get_knot_locations_window_override (tests/placement/test_spline_placement_adapter.py): a one-knot window returns the step. Fails on main, passes here.

The first three are in tests/expansion/spline/test_spline_expansions.py.

Reproduction

Output of the script from #69 on this branch, with LeakageWarnings filtered. The script is extended with the per-family adaptive widths, including the [20, 50] window, and the simulation script from the issue:

output_dim=5: B-spline interior knots [4.997] | RBF centers (same count) [4.997]
output_dim=6: B-spline interior knots [0.96, 4.997] | RBF centers (same count) [0.96, 4.997]
output_dim=7: B-spline interior knots [0.96, 4.997, 9.706] | RBF centers (same count) [0.96, 4.997, 9.706]
adaptive window [5, 20]: B-spline width 20 | RBF width 20
adaptive window [5, 50]: B-spline width 34 | RBF width 30
adaptive window [5, 20]: BSpline 20, MSpline 20, ISpline 20, CubicRegressionSpline 20, NaturalCubicSpline 20
adaptive window [5, 50]: BSpline 34, MSpline 34, ISpline 34, CubicRegressionSpline 33, NaturalCubicSpline 31
adaptive window [20, 50]: BSpline 34, MSpline 34, ISpline 34, CubicRegressionSpline 33, NaturalCubicSpline 31
simulation (missed step, [B-spline, RBF]): {6: [22, 22], 7: [19, 19]}

The B-spline now misses the step exactly as often as the RBF with the same number of centers. On main it missed 199 and 137 of 200. The remaining misses come from the CART selector's spacing filter, which is #70.

Checks

  • just test: 1453 passed, 59 skipped, 7 xfailed; coverage 93.57% (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): 1440 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.

The B/M/I, cubic-regression and natural-cubic splines asked their
target-aware selector for a fixed 3-15 basis-function window and then
reduced the result to the needed count with select_knots, which keeps
evenly spaced indices of the sorted list. That positional trim
routinely discarded the split where the target changes, and capped
adaptive widths at 15 (14 / 12 for the cubic families); a window lying
entirely above the cap collapsed to min_output_dim.

SplinePlacementAdapter.get_knot_locations now accepts a per-call
min_knots / max_knots window, and the transformers pass the window
resolved from output_dim or [min_output_dim, max_output_dim], so the
selector's importance ranking keeps the most informative splits. The
spline and adapter docstrings describe the window, and the adaptive
tutorial output is updated (both signals now reach the [5, 20] bound).

Fixes #69

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.

Target-aware splines trim selector knots by position, dropping the dominant split, and cap adaptive width at 15

1 participant