Skip to content

fix(placement): fill tied-data locations with distinct interior points - #102

Open
ChrisW09 wants to merge 1 commit into
mainfrom
fix/57-selector-tied-locations
Open

ChrisW09 wants to merge 1 commit into
mainfrom
fix/57-selector-tied-locations

Conversation

@ChrisW09

@ChrisW09 ChrisW09 commented Oct 16, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #57.

On discrete or heavily tied features, the target-aware location selectors ("cart" and "lightgbm" placement) returned too few, duplicate or range-boundary locations. Feature maps and PLE came out narrower than output_dim with constant or duplicate columns, and CrossFittedTransformer failed with a misleading "Disable adaptive sizing" error.

Root cause

A tree on a feature with few levels can only split between levels, so it finds fewer splits than requested, and the selector tops up with quantile locations. BaseLocationSelector._supplement took the first missing quantile candidates before de-duplicating them. It also did not restrict them to the open range (x_min, x_max) and did not apply the spacing filter. On tied data the quantiles repeat and land on the boundary values. The small-sample and no-candidate fallbacks in select returned quantile_knots() unchanged, duplicates and boundary values included. LightGBM reports a split at zero as the sentinel threshold 1e-35, which was kept as a separate location next to 0.0. PLE then turned boundary and duplicate thresholds into zero-width bins, which it encodes as a constant 0.5.

Fix

  • BaseLocationSelector._supplement keeps every location the selector found and fills only the shortfall with distinct candidates strictly inside the range that keep min_location_spacing from every kept location: evenly spread quantile candidates first, then uniform ones. Only when the request is too dense for the spacing are the remaining uniform points added without it. A zero-range feature keeps its repeated value, as before.
  • Both fallbacks in BaseLocationSelector.select (too few samples, no candidates) now go through _supplement instead of returning quantile_knots() directly.
  • LightGBMLocationSelector._extract_split_points_with_gains / _traverse_tree map the ±1e-35 split-at-zero sentinel to the midpoint between the values it separates (_midpoint), which is where CART would put that split.
  • PLETransformer encodes the position inside any bin of positive width. Before, it treated bins narrower than an absolute 1e-10 as zero-width and filled them with 0.5.

Behaviour changes

  • On discrete or tied features, target-aware PLE thresholds, feature-map centers and selector-placed spline knots move. They are now distinct and strictly inside the observed range, and the requested count holds. In the issue's example, RBF gets 10 centers instead of 7, PLE with output_dim=6 has no constant column, and the cross-fitting call no longer raises.
  • LightGBM placement reports the split between 0 and the positive values at the bin midpoint (for example 0.5) instead of at 1e-35.
  • When the tree finds fewer than min_count splits on continuous data, the top-up now takes evenly spread, spaced quantile candidates instead of the lowest ones. When the tree finds enough splits, nothing changes, and the golden regression baselines pass unchanged.
  • PLE encodes the in-bin position for bins narrower than 1e-10 (features on a tiny scale) instead of a constant 0.5.

Documentation

  • docs/representations/choosing_a_method.md (edge-case behaviour): new "Discrete or heavily tied column" entry. Target-aware PLE and feature maps still get the requested number of distinct interior bin edges or centers, so the output width depends neither on the data nor on the cross-fitting fold.
  • Docstrings: the pretab/core/selectors.py module, BaseLocationSelector, the Returns section of BaseLocationSelector.select (between min_count and max_count distinct interior locations; a single-valued feature repeats its value), and _supplement.
  • The contracts quoted in the issue (numerical_encoding.md: "output_dim is exact here"; the feature-map base docstring: "keeps exactly output_dim centers") already describe the fixed behaviour and are unchanged.

Tests

  • test_selector_tops_up_tied_features_with_distinct_interior_locations[{2,3,5}-{CART,LightGBM}LocationSelector]: 8 distinct, strictly interior locations on features with 2, 3 and 5 levels, for both selectors. Fails on main, passes here.
  • test_supplement_on_tied_data_respects_spacing_and_count: the top-up on tied data returns the requested count, keeps the existing split, respects the spacing and stays inside the range. Fails on main, passes here.
  • test_small_sample_fallback_on_tied_data_is_distinct_and_interior: the small-sample fallback returns distinct interior locations. Fails on main, passes here.
  • test_lightgbm_zero_split_sentinel_maps_to_the_bin_midpoint: 0.5 is selected and no location is near 1e-35. Fails on main, passes here.
  • test_cross_fitting_a_discrete_feature_keeps_a_fixed_width[0-4]: CrossFittedTransformer around a target-aware RBF on a 6-level feature returns 10 columns. Fails on main (raises), passes here.
  • test_target_aware_centers_keep_output_dim_on_a_discrete_feature[cart, lightgbm]: 10 distinct centers and 10 output columns. Fails on main, passes here.
  • test_ple_keeps_output_dim_and_no_constant_columns_on_discrete_features[2-6]: width 7 and no constant column on features with 2 to 6 levels. Fails on main, passes here.
  • test_ple_encodes_the_position_inside_narrow_bins_on_a_tiny_range_feature: on a feature of range 1e-11, each value is encoded as (x - lower) / (upper - lower) in its bin. Fails on main and with the selector change alone, passes here.
  • test_supplement_fills_dense_requests_beyond_the_spacing: 150 distinct points on [0, 1], more than the 1% spacing allows. Guard test: passes on both by design.
  • test_constant_feature_fallback_keeps_the_requested_count: a zero-range feature repeats its value min_count times. Guard test: passes on both by design.

Reproduction

Output of the script from #57 on this branch (LeakageWarnings omitted):

RBF centers: [0.36363636363636365, 0.5, 1.0, 1.5, 1.8181818181818183, 2.0, 2.5, 3.0, 3.5, 3.6363636363636367] -> width 10
PLE thresholds: [0.5, 1.0, 1.5, 2.5, 3.5] column std: [0.392, 0.392, 0.479, 0.446, 0.422, 0.433]
CrossFittedTransformer output shape: (200, 10)

Checks

  • just test: 1462 passed, 59 skipped, 7 xfailed; coverage 93.73% (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): 1444 passed, 77 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.

On discrete or heavily tied features the selector's quantile top-up and its
quantile fallbacks collapsed onto the tied values and the range boundary. The
top-up sliced the quantile candidates before de-duplicating them, did not
restrict them to the open range and skipped the spacing filter, and the
fallbacks returned quantile_knots() verbatim. The result was too few locations
(output narrower than output_dim, and fold-dependent widths that crashed
CrossFittedTransformer with a misleading "disable adaptive sizing" error),
duplicate locations, and locations on x_min/x_max that gave PLE a constant
column.

The top-up and both fallbacks now keep every selector-found location and fill
the shortfall with strictly interior, spaced quantile candidates first, then
uniform ones; only a request too dense for the spacing adds the remaining
uniform points without it, and a zero-range feature keeps its repeated value.
LightGBM's +/-1e-35 split-at-zero sentinel is mapped to the midpoint of the
values it separates, and PLE treats any positive bin width as a real bin
instead of using an absolute 1e-10 tolerance that encoded narrow bins as a
constant 0.5.

Locations move on tied or discrete features only; continuous features with
enough splits are unchanged.

Fixes #57

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 location selectors return too few, duplicate, or boundary locations on tied/discrete features

1 participant