Skip to content

fix(splines): keep knots unique and strictly interior on tied data - #101

Open
ChrisW09 wants to merge 1 commit into
mainfrom
fix/56-unique-interior-knots
Open

ChrisW09 wants to merge 1 commit into
mainfrom
fix/56-unique-interior-knots

Conversation

@ChrisW09

@ChrisW09 ChrisW09 commented Oct 16, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #56.

On tied features (top-coded, zero-inflated or low-cardinality), quantile spline knots repeated or landed on the range boundary. The basis then had dead or duplicate columns, and for B/M-splines every row at x_max (and every value above it) became an all-zero row. This hit the default BSplineTransformer, MSplineTransformer and ISplineTransformer, Preprocessor(numerical_method="bspline"), and the natural-cubic, cubic-regression and tensor-product splines with placement_strategy="quantile".

Root cause

On tied data, the quantiles returned by quantile_knots / spanning_knots coincide, and often equal the minimum or maximum. BaseSplineTransformer._adjust_internal_knots and _column_knots clipped interior knots into the closed range [x_min, x_max] and de-duplicated them only among themselves. A knot equal to a boundary therefore raised that boundary's multiplicity to degree + 2. SplineBasisMixin._place_spanning_knots and _place_interior_knots returned the unsupervised knots without any de-duplication. In both cases a basis function's support collapsed to a point.

Fix

  • New pretab.core.knots.supplement_interior_knots(x, knots, count), exported in __all__. It drops knots on or outside [min(x), max(x)] and repeated knots, and keeps every remaining knot. If fewer than count remain, it fills the shortfall from evenly spread quantile and uniform candidates that are strictly interior and not yet present. The uniform candidates guarantee the top-up for any feature with a positive range. It never down-samples.
  • B/M/I splines: BaseSplineTransformer._supplement_knots delegates to the helper. _adjust_internal_knots now always runs it with min_knots, then trims an overfull set with select_knots as before. Before, it clipped into the closed range and topped up only a short set. The closed-range np.clip / np.unique at the end of _column_knots is removed because the knots are already strictly interior and unique.
  • SplineBasisMixin._place_spanning_knots (natural cubic, unsupervised path) keeps the two endpoints once and passes the interior knots through the helper. _place_interior_knots (cubic regression, and the tensor product through _place_bspline_knots, unsupervised path) passes its knots through the helper.

Behaviour changes

  • Unsupervised "quantile" knots move on tied data. Repeated knots and boundary knots are replaced by strictly interior quantile / uniform knots. The natural cubic spline keeps each endpoint once. Untied data and "uniform" placement give the same knots as before, and the golden regression baselines pass unchanged.
  • A short knot set now keeps every strictly interior knot and fills only the shortfall. Before, the union of the existing knots and the quantile / uniform candidates was down-sampled, which could move or drop existing knots. This applies to explicit knot_locations and to target-aware B/M/I selector locations. For example, BSplineTransformer(output_dim=6, knot_locations=[5, 5]) on [0, 10] now gives the interior knots [3.33, 5.0] instead of [3.33, 6.67].
  • Explicit knot_locations, and B/M/I selector locations, on or outside a feature's range are dropped and replaced by strictly interior knots instead of being clipped onto the boundary. With output_dim=6 on [0, 10], [0, 5] gives [3.33, 5.0] (before: [0.0, 5.0], with column 0 dead), and [-5, 15] gives [3.33, 6.67] (before: [0.0, 10.0], with columns 0 and 5 dead). The PR for 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) changes the explicit-knot path again; see Merge notes.
  • A column with a single finite value, all other values missing, has no interior. On it, NaturalCubicSplineTransformer(output_dim=6) now returns 1 column instead of 6, five of which were NaN. CubicRegressionSplineTransformer(output_dim=6) returns 3 columns instead of 6. The B/M/I splines still raise on such a column, as on main.

Documentation

  • supplement_interior_knots: NumPy docstring.
  • BaseSplineTransformer docstring (B-, M- and I-spline): placement_strategy says that interior knots are always unique and strictly inside the feature's range, and that repeated or boundary quantiles on tied data are replaced. knot_locations says that repeated knots and knots on or outside the range are dropped and replaced.
  • placement_strategy of CubicRegressionSplineTransformer, NaturalCubicSplineTransformer (endpoints kept once) and TensorProductSplineTransformer: the tied-data rule.
  • Docstrings of _adjust_internal_knots, _supplement_knots and _place_interior_knots.
  • docs/core_concepts/resolution_and_placement.md: a paragraph on tied data after the placement table.

Tests

All tests are in the new module tests/expansion/spline/test_tied_knot_placement.py. On main the module fails at import because supplement_interior_knots is new. With that import stubbed, every test below fails on main.

  • test_bmi_interior_knots_are_unique_and_strictly_inside[{top_coded,zero_inflated}-{B,M,I}SplineTransformer]: the interior knot count is right, the knots are unique and strictly inside the range, each boundary has multiplicity exactly degree + 1, and no column is dead on a dense grid. Fails on main, passes here.
  • test_bspline_is_a_partition_of_unity_at_the_cap: rows sum to 1 on the top-coded data and at 100 and 150. Fails on main, passes here.
  • test_mspline_keeps_every_row_at_the_cap: there is no all-zero row and no dead column, and the rows at 100 and 150 are non-zero. Fails on main, passes here.
  • test_preprocessor_bspline_has_no_dead_column_on_a_top_coded_feature: the issue's Preprocessor case has no dead column and no all-zero row. Fails on main, passes here.
  • test_ispline_equals_one_at_the_cap: the I-spline basis is 1 at the maximum. Fails on main, passes here.
  • test_cubic_families_place_unique_knots_on_zero_inflated_data[{NaturalCubic,CubicRegression}SplineTransformer]: knots are unique, the basis has full rank on a dense grid, and no column is dead. Fails on main, passes here.
  • test_natural_spline_keeps_both_endpoints_once: 7 knots, the endpoints at the minimum and maximum, the interior strictly inside. Fails on main, passes here.
  • test_tensor_product_has_no_degenerate_columns_on_zero_inflated_marginal[6, 8]: no dead column on a grid with a zero-inflated marginal. Fails on main, passes here.
  • test_low_cardinality_feature_keeps_a_full_rank_bspline_basis[levels0-None, levels1-None, levels2-p2]: features with 2 and 3 levels, and a 4-level feature with most of its mass on the end levels, keep a rank-6 basis on the range. Fails on main, passes here.
  • test_four_level_feature_keeps_every_row_at_a_wide_bspline_basis: on integers 0-3 at output_dim=8, rows sum to 1, the rank is 4 on the training rows and 8 on the range. Fails on main, passes here.
  • test_supplement_interior_knots_drops_boundary_and_duplicate_knots and test_supplement_interior_knots_never_down_samples: the helper's contract. They fail on main, where the helper does not exist, and pass here.

Reproduction

Output of the script from #56 on this branch. The script is extended with the M-spline, Preprocessor, tensor-product and integer cases from the issue text:

bspline knots_: [0.03, 0.03, 0.03, 0.03, 33.35, 58.77, 100.0, 100.0, 100.0, 100.0]
column max: [1.0, 0.571, 0.629, 0.6, 0.552, 1.0]
transform(100): [[0.0, 0.0, 0.0, 0.0, 0.0, 1.0]]  transform(150): [[0.0, 0.0, 0.0, 0.0, 0.0, 1.0]]
rows with all-zero basis: 0 of 1000
ispline transform(100): [[1.0, 1.0, 1.0, 1.0, 1.0, 1.0]]
naturalspline knots_: [0.0, 0.495, 4.145, 4.782, 14.347, 23.912, 28.694]  all-zero cols: []
cubicspline rank: 6 of 6
mspline dead cols: []  all-zero rows: 0
preprocessor bspline dead cols: [] of 7  all-zero rows: 0
tensor product dead cols: 0 of 36
integers 0-3, output_dim=8: knots_: [0.0, 0.0, 0.0, 0.0, 0.6, 1.0, 2.0, 2.4, 3.0, 3.0, 3.0, 3.0]
  all-zero rows: 0  rank on data: 4  rank on [0, 3]: 8

In the integer case, eight columns over four distinct values cannot all be informative on the training rows, so the rank there is at most 4. Columns 1 and 6 are still zero at the four observed values, but no basis function collapses, every row keeps a full basis, and the basis has full rank on the range. On main the same case had dead columns 0, 6 and 7 and 263 all-zero rows.

Checks

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

Quantile knots on top-coded, zero-inflated or low-cardinality features
repeat and land on the range boundary. The B/M/I base clipped knots into
the closed range, and the natural-cubic, cubic-regression and
tensor-product paths used them verbatim, so basis functions collapsed to
a point: dead or duplicate columns, and all-zero B/M-spline rows at
x_max.

A shared supplement_interior_knots helper in pretab.core.knots now drops
boundary and repeated knots and tops the set up from strictly interior
quantile and uniform candidates, keeping every existing knot. Both
spline construction paths use it. Knots on untied data are unchanged.
The placement_strategy and knot_locations docstrings and the placement
guide describe the rule.

Fixes #56

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.

Spline knots on tied data land on the range boundary or repeat, producing dead columns and all-zero rows

1 participant