Repository navigation
Conversation
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 was referenced Oct 16, 2026
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 defaultBSplineTransformer,MSplineTransformerandISplineTransformer,Preprocessor(numerical_method="bspline"), and the natural-cubic, cubic-regression and tensor-product splines withplacement_strategy="quantile".Root cause
On tied data, the quantiles returned by
quantile_knots/spanning_knotscoincide, and often equal the minimum or maximum.BaseSplineTransformer._adjust_internal_knotsand_column_knotsclipped 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 todegree + 2.SplineBasisMixin._place_spanning_knotsand_place_interior_knotsreturned the unsupervised knots without any de-duplication. In both cases a basis function's support collapsed to a point.Fix
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 thancountremain, 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.BaseSplineTransformer._supplement_knotsdelegates to the helper._adjust_internal_knotsnow always runs it withmin_knots, then trims an overfull set withselect_knotsas before. Before, it clipped into the closed range and topped up only a short set. The closed-rangenp.clip/np.uniqueat the end of_column_knotsis 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
"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.knot_locationsand 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].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. Withoutput_dim=6on[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.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 onmain.Documentation
supplement_interior_knots: NumPy docstring.BaseSplineTransformerdocstring (B-, M- and I-spline):placement_strategysays 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_locationssays that repeated knots and knots on or outside the range are dropped and replaced.placement_strategyofCubicRegressionSplineTransformer,NaturalCubicSplineTransformer(endpoints kept once) andTensorProductSplineTransformer: the tied-data rule._adjust_internal_knots,_supplement_knotsand_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. Onmainthe module fails at import becausesupplement_interior_knotsis new. With that import stubbed, every test below fails onmain.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 exactlydegree + 1, and no column is dead on a dense grid. Fails onmain, 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 onmain, 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 onmain, passes here.test_preprocessor_bspline_has_no_dead_column_on_a_top_coded_feature: the issue'sPreprocessorcase has no dead column and no all-zero row. Fails onmain, passes here.test_ispline_equals_one_at_the_cap: the I-spline basis is 1 at the maximum. Fails onmain, 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 onmain, passes here.test_natural_spline_keeps_both_endpoints_once: 7 knots, the endpoints at the minimum and maximum, the interior strictly inside. Fails onmain, 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 onmain, 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 onmain, passes here.test_four_level_feature_keeps_every_row_at_a_wide_bspline_basis: on integers 0-3 atoutput_dim=8, rows sum to 1, the rank is 4 on the training rows and 8 on the range. Fails onmain, passes here.test_supplement_interior_knots_drops_boundary_and_duplicate_knotsandtest_supplement_interior_knots_never_down_samples: the helper's contract. They fail onmain, 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: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
mainthe 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%).scripts/quickstart.pypasses.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).Merge notes
main. The PR for Unsupervised quantile placement repeats RBF/ReLU/sigmoid/tanh centers on tied data, duplicating columns #82 (fix(placement): return distinct quantile locations on tied data #119) is stacked on this branch because it reusessupplement_interior_knots. That PR must be retargeted tomainafter this one merges.pretab/expansion/spline/base.py. In both, take B/M/I spline knot_locations are sized by output_dim and clipped to the data range instead of used as given #81's semantics for explicit knots:knot_locationsdocstring, keep B/M/I spline knot_locations are sized by output_dim and clipped to the data range instead of used as given #81's paragraph: explicit knots set the width, repeated knots are merged, and knots on or outside the range are dropped with aDataWarningand not replaced._column_knots, drop B/M/I spline knot_locations are sized by output_dim and clipped to the data range instead of used as given #81's trailingnp.clip/np.unique, which this PR removes. Also drop this PR's two boundary-vector lines, because B/M/I spline knot_locations are sized by output_dim and clipped to the data range instead of used as given #81 defines them at the top of the method. The conflict block resolves to nothing, and only thereturn np.concatenate(...)follows._explicit_knotsand no longer pass through_adjust_internal_knots. This PR's drop-and-replace behaviour forknot_locationsis then superseded by B/M/I spline knot_locations are sized by output_dim and clipped to the data range instead of used as given #81's.SplineBasisMixin._clamp_interior_knots, which the target-aware cubic-regression and natural-cubic paths use. It still tops up a short selector set by down-sampling the union of selector, quantile and uniform knots. Replace that block withknots = supplement_interior_knots(x, knots, min_count)so that every selector knot is kept, and update its docstring to match. This only matters for target-aware cubic splines on tied data.pretab/expansion/spline/base.py. Each merges cleanly with this branch.This change was previously part of #75, which is closed in favour of one pull request per issue.