Skip to content

fix(placement): label-encode classification targets for LightGBM - #105

Open
ChrisW09 wants to merge 1 commit into
mainfrom
fix/61-lightgbm-classification-labels
Open

ChrisW09 wants to merge 1 commit into
mainfrom
fix/61-lightgbm-classification-labels

Conversation

@ChrisW09

@ChrisW09 ChrisW09 commented Oct 16, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #61.

With placement_strategy="lightgbm" and task="classification", target-aware placement handled only targets coded with one class ≤ 0 and the other > 0. For labels such as {1, 2} it silently fell back to quantiles, for multiclass targets it found only the class-0 boundary, and for string labels fit raised. This affected PLE, the feature maps and the knot splines.

Root cause

LightGBMLocationSelector._ordered_candidates set objective="binary" for every classification task and passed the raw labels to lgb.Dataset. LightGBM's binary objective needs numeric labels and treats every label > 0 as positive. There was no label encoding and no multiclass objective. When no split was found, select fell back to quantile locations without a warning.

Fix

In LightGBMLocationSelector._ordered_candidates, a classification target is label-encoded with np.unique(y_valid, return_inverse=True) and the objective follows the number of classes:

  • two classes: binary (binary_logloss);
  • more than two: multiclass with num_class (multi_logloss);
  • a single class has no split to learn: the method returns no candidates, and select takes its unsupervised fallback.

Regression is unchanged.

Behaviour changes

  • LightGBM placement with task="classification" now follows the class boundaries for labels other than {0, 1}, for multiclass targets and for string labels. In the issue's example, the {1, 2} target gets [0.901 0.972] instead of the quantiles [0.367 0.685], the three-class target gets [0.094 0.901], and string labels no longer raise.
  • {0, 1} targets are unaffected: their codes equal the labels.
  • A single-class classification target with string labels now takes the unsupervised fallback instead of raising.

Documentation

  • LightGBMLocationSelector class docstring: new paragraph on the classification handling (label encoding, so any class labels work as with the CART selector; binary for two classes, multiclass for more; a single class falls back to unsupervised locations).
  • No docs page changes: docs/core_concepts/resolution_and_placement.md presents "lightgbm" as an alternative to "cart" with no restriction on the labels, which now holds.

Tests

  • test_lightgbm_classification_places_a_location_at_the_class_boundary[labels-1-2, three-classes, string-labels]: a location lies within 0.01 of the class boundary at 0.9. Fails on main, passes here.
  • test_lightgbm_classification_with_a_single_class_falls_back: a single-class string target returns the unsupervised fallback [1/3, 2/3] on linspace(0, 1, 200). Fails on main (ValueError: could not convert string to float), passes here.

Reproduction

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

quantile_knots: [0.367 0.685]
labels {1,2}, boundary 0.9       cart      [0.367 0.901]
labels {1,2}, boundary 0.9       lightgbm  [0.901 0.972]
3 classes, boundaries 0.1/0.9    cart      [0.1   0.901]
3 classes, boundaries 0.1/0.9    lightgbm  [0.094 0.901]
string labels                    cart      [0.367 0.901]
string labels                    lightgbm  [0.901 0.972]

Checks

  • just test: 1442 passed, 59 skipped, 7 xfailed; coverage 93.67% (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): 1425 passed, 76 skipped, 7 xfailed; scripts/quickstart.py passes. The new tests skip there because lightgbm is not installed.
  • 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.

LightGBMLocationSelector always trained LightGBM's binary objective on the raw
labels. Labels other than {0, 1} looked single-class, so no split was found and
placement silently fell back to quantiles; multiclass targets only found the
class-0 boundary; and string labels raised in fit.

For task="classification" the target is now label-encoded and trained with the
binary objective for two classes and the multiclass objective (num_class) for
more, matching what the CART selector supports. A single-class target has no
split to learn and takes the unsupervised fallback instead of failing.

Fixes #61

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.

LightGBM placement with task="classification" always uses the binary objective on raw labels

1 participant