Skip to content

fix(lightgbm): de-duplicate feature names so training no longer fails on repeated columns - #2508

Merged
Rana Singh (ranadeepsingh) merged 9 commits into
microsoft:masterfrom
ranadeepsingh:rana/fix-lightgbm-error
Aug 12, 2026
Merged

fix(lightgbm): de-duplicate feature names so training no longer fails on repeated columns#2508
Rana Singh (ranadeepsingh) merged 9 commits into
microsoft:masterfrom
ranadeepsingh:rana/fix-lightgbm-error

Conversation

@ranadeepsingh

@ranadeepsingh Rana Singh (ranadeepsingh) commented Feb 27, 2026

Copy link
Copy Markdown
Collaborator

Why

Fixes #2242.

LightGBM refuses to build a Dataset whose feature names repeat. Spark can hand it repeated names in two ways — through the AttributeGroup metadata of the features column, or through a user-supplied slotNames — and either one killed training:

Dataset set feature names call failed in LightGBM with error:
Feature (Column_) appears more than one time.

The names are perfectly usable; only their uniqueness is a LightGBM requirement. So the fix is to make them unique before LightGBM ever sees them, rather than failing the job.

What changed

slotNames param ──┐
                  ├─► getSlotNamesWithMetadata ─► ensureUniqueFeatureNames ─► LightGBM
AttributeGroup ───┘                                (rename dupes, keep order)
Change Detail
De-duplicate feature names Both metadata-derived and explicit names now pass through ensureUniqueFeatureNames, which suffixes repeats while preserving order (LightGBM feature names are positional). A warning lists what was renamed.
Name the streaming reference Dataset createReferenceDatasetFromSample now takes the feature names and applies them, so the reference Dataset matches the per-partition Datasets. Split into createDatasetFromSamples / setFeatureNamesIfProvided / serializeAndCleanup.
Validate slot names unconditionally validateSlotNames previously ran only when AttributeGroup metadata was present, so explicit slotNames were never checked.
Validate before the first native call Naming the reference Dataset introduces an LGBM_DatasetSetFeatureNames call earlier in trainOneDataBatch than the old validation point, so validation moved up to where featuresSchema resolves.
Fix native handle leaks createDatasetFromSamples frees its voidpp handle in a finally; the reference Dataset is freed in a finally spanning naming and serialization.

Correctness notes

Three of these were found by testing, not by reading — each is covered by a test confirmed to fail against the simpler implementation.

Subtlety Why the obvious approach is wrong
Generated names can collide with later originals Remembering only emitted names turns ["Column_", "Column_", "Column__1"] into a collision on Column__1 — the exact error this PR fixes. All original names are reserved up front.
LightGBM normalizes spaces to underscores before checking duplicates "a b" and "a_b" are two strings in Scala but one feature natively. Uniqueness is now decided on the normalized form while the original names are what get emitted.
Validation must precede the native call LightGBM's own rejection is Do not support special JSON characters in feature name. — it never says which column. Validating late let that opaque error win and broke …bad column names fails early. It now fails in 0.3s with an actionable IllegalArgumentException.

Two smaller corrections: the invalid-name message listed \ as disallowed, but neither the regex nor LightGBM's CheckAllowedJSON rejects it (which accepts exactly " , : [ ] { }) — the message now matches reality, and the regex is deliberately unchanged. And when the name count disagrees with numCols, names are skipped with a warning rather than risking an out-of-bounds native read.

No bulk-mode retry. An earlier revision caught the failure and retried with dataTransferMode=bulk. Removed: BulkPartitionTask calls setFeatureNames with the same duplicated names, so the retry fails identically — and it mutated the estimator's param mid-fit, visible to concurrent callers. Deduplicating up front removes the need for a fallback.

Behavior changes

  • Repeated feature names are renamed instead of fatal.
  • validateSlotNames now also checks explicitly supplied slotNames. A job passing slotNames containing " , : [ ] { } with no attribute metadata previously reached LightGBM unchecked; it now fails fast with the existing, actionable error. This is strictly a better diagnostic — those names were already rejected by LightGBM, just opaquely and later.

How was this patch tested?

Added to VerifyLightGBMCommon:

Test Covers
Verify duplicate feature names are handled correctly Repeats via AttributeGroup metadata
Verify explicit slotNames parameter is used Explicit slotNames still applied
Verify duplicate explicit slotNames are made unique Repeats via user-supplied slotNames
Verify a generated slot name cannot collide with a later original name Collision case — verified to fail against the naive implementation
Verify names differing only by space vs underscore are made unique Native normalization — verified to fail before the fix

Locally on Java 11 with the full CI datasets: VerifyLightGBMCommon 9/9; the stream/bulk regressor, ranker and classifier suites plus NetworkManagerSuite and TrainUtilsSuite 52/52 (including …bad column names fails early); lightgbm/scalastyle and lightgbm/Test/scalastyle 0 errors.

Checklist

  • Tests added
  • No dependency changes
  • Bug fix, not a new feature — no website samples needed

@ranadeepsingh

Copy link
Copy Markdown
Collaborator Author

/azp run

@github-actions

Copy link
Copy Markdown

Hey Rana Singh (@ranadeepsingh) 👋!
Thank you so much for contributing to our repository 🙌.
Someone from SynapseML Team will be reviewing this pull request soon.

We use semantic commit messages to streamline the release process.
Before your pull request can be merged, you should make sure your first commit and PR title start with a semantic prefix.
This helps us to create release messages and credit you for your hard work!

Examples of commit messages with semantic prefixes:

  • fix: Fix LightGBM crashes with empty partitions
  • feat: Make HTTP on Spark back-offs configurable
  • docs: Update Spark Serving usage
  • build: Add codecov support
  • perf: improve LightGBM memory usage
  • refactor: make python code generation rely on classes
  • style: Remove nulls from CNTKModel
  • test: Add test coverage for CNTKModel

To test your commit locally, please follow our guild on building from source.
Check out the developer guide for additional guidance on testing your change.

@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines successfully started running 1 pipeline(s).

@ranadeepsingh Rana Singh (ranadeepsingh) changed the title bugfix: LightGBM Ensure non-duplicate column names fix: LightGBM Ensure non-duplicate column names Feb 27, 2026
@codecov-commenter

Codecov Comments Bot (codecov-commenter) commented Feb 27, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 87.03%. Comparing base (c570407) to head (d9a915e).

Additional details and impacted files
@@            Coverage Diff             @@
##           master    #2508      +/-   ##
==========================================
+ Coverage   86.76%   87.03%   +0.27%     
==========================================
  Files         338      338              
  Lines       18785    18817      +32     
  Branches     1804     1803       -1     
==========================================
+ Hits        16299    16378      +79     
+ Misses       2486     2439      -47     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@ranadeepsingh

Copy link
Copy Markdown
Collaborator Author

/azp run

@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines successfully started running 1 pipeline(s).

@ranadeepsingh

Copy link
Copy Markdown
Collaborator Author

/azp run

@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines successfully started running 1 pipeline(s).

@ranadeepsingh

Copy link
Copy Markdown
Collaborator Author

/azp run

@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines successfully started running 1 pipeline(s).

@ranadeepsingh

Copy link
Copy Markdown
Collaborator Author

/azp run

@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines successfully started running 1 pipeline(s).

@ranadeepsingh

Copy link
Copy Markdown
Collaborator Author

/azp run

@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines successfully started running 1 pipeline(s).

Copilot AI lite review requested due to automatic review settings August 11, 2026 22:21
@ranadeepsingh

Copy link
Copy Markdown
Collaborator Author

/azp run

@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
Successfully started running 1 pipeline(s).

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR fixes LightGBM training failures caused by duplicate feature names (including collisions after LightGBM’s space-to-underscore normalization) by ensuring slot/feature names are unique and validated before the first native call that consumes them. It also propagates feature names onto the streaming reference Dataset and refactors reference-dataset creation for clearer lifecycle management.

Changes:

  • De-duplicate feature/slot names (from both slotNames and AttributeGroup metadata) using LightGBM-style normalization rules.
  • Validate slot names earlier in fit to fail fast with actionable errors before native feature-name calls.
  • Name the streaming reference Dataset consistently and add regression tests covering duplicate/collision scenarios.
Show a summary per file
File Description
lightgbm/src/main/scala/com/microsoft/azure/synapse/ml/lightgbm/LightGBMBase.scala Ensure unique feature names (including normalized collisions) and validate names before native calls.
lightgbm/src/main/scala/com/microsoft/azure/synapse/ml/lightgbm/dataset/ReferenceDatasetUtils.scala Refactor reference Dataset creation and (now) apply feature names to reference datasets.
lightgbm/src/test/scala/com/microsoft/azure/synapse/ml/lightgbm/split1/VerifyLightGBMCommon.scala Add tests for duplicate feature names from metadata and explicit slotNames, including collision edge cases.

Review details

Tip

Configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Suppressed comments (1)

lightgbm/src/main/scala/com/microsoft/azure/synapse/ml/lightgbm/dataset/ReferenceDatasetUtils.scala:94

  • serializeAndCleanup frees the Dataset handle. If the caller also adds defensive cleanup (needed for failures before serialization), this risks double-free. Prefer making Dataset lifetime ownership explicit: serialize here, but free the Dataset in the caller's finally so all failure paths free exactly once.
    LightGBMUtils.validate(lightgbmlib.LGBM_DatasetSerializeReferenceToBinary(
      datasetHandle, bufferHandlePtr, lenPtr), "Serialize ref")
    val bufferLen: Int = lightgbmlib.intp_value(lenPtr)
    log.info(s"Created serialized reference dataset of length $bufferLen")
    LightGBMUtils.validate(lightgbmlib.LGBM_DatasetFree(datasetHandle), "Free Dataset")
    toByteArray(bufferHandlePtr, bufferLen)
  • Files reviewed: 3/3 changed files
  • Comments generated: 2
  • Review effort level: Lite

Copilot AI review requested due to automatic review settings August 11, 2026 23:05
@ranadeepsingh

Copy link
Copy Markdown
Collaborator Author

/azp run

@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
Successfully started running 1 pipeline(s).

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review details

Suppressed comments (2)

lightgbm/src/main/scala/com/microsoft/azure/synapse/ml/lightgbm/LightGBMBase.scala:587

  • getSlotNamesWithMetadata (and therefore ensureUniqueFeatureNames) is invoked multiple times during a single fit in streaming mode (e.g., once inside calculateRowStatistics and again when building the TrainingContext in executeTraining). Since ensureUniqueFeatureNames logs a warning when it renames duplicates, the same warning can be emitted more than once per batch, which is noisy and can look like multiple independent problems. Consider computing the (unique) feature name array once per batch and threading it through to all consumers.
    // Get feature names to set on the reference dataset (ensures unique names for Spark 3.5+)
    val featureNames = getSlotNamesWithMetadata(featuresSchema)

lightgbm/src/main/scala/com/microsoft/azure/synapse/ml/lightgbm/LightGBMBase.scala:287

  • validateSlotNames currently validates the de-duplicated names returned by getSlotNamesWithMetadata. If the input contains invalid characters and also has duplicates, ensureUniqueFeatureNames can append a suffix (e.g., bad,name_1), and the thrown IllegalArgumentException will list names the user never supplied, making the error harder to act on. Validate the raw names first (either explicit slotNames or metadata-derived names) and only then de-duplicate for LightGBM.

This issue also appears on line 585 of the same file.

  private def validateSlotNames(featuresSchema: StructField): Unit = {
    val slotNamesOpt = getSlotNamesWithMetadata(featuresSchema)
    val pattern = new Regex("[\",:\\[\\]{}]")
    slotNamesOpt.foreach(slotNames => {
      val badSlotNames = slotNames.flatMap(slotName =>
        if (pattern.findFirstIn(slotName).isEmpty) None else Option(slotName))
      if (!badSlotNames.isEmpty) {
  • Files reviewed: 3/3 changed files
  • Comments generated: 0 new
  • Review effort level: Lite

Rebase onto current master and address three defects found while reviewing
the original change.

Duplicate detection was incomplete in two ways, and both still produced the
error this branch exists to fix:

- A generated name could collide with an original name appearing later, so
  ["Column_", "Column_", "Column__1"] still failed with
  "Feature (Column__1) appears more than one time". All original names are
  now reserved up front.
- LightGBM replaces spaces with underscores before comparing feature names,
  so "a b" and "a_b" are one feature natively and failed with
  "Feature (a_b) appears more than one time". Uniqueness is now decided on
  the normalized form while the original names are still emitted.

Naming the streaming reference Dataset introduces an LGBM_DatasetSetFeatureNames
call inside calculateRowStatistics, which runs earlier in trainOneDataBatch
than validateSlotNames did. Invalid names therefore surfaced as the opaque
native "Do not support special JSON characters in feature name" instead of the
actionable IllegalArgumentException, breaking the existing
"Verify LightGBM Regressor with bad column names fails early" test.
validateSlotNames now runs as soon as featuresSchema is resolved.

Also:

- Remove the bulk-mode retry fallback. Its guard required the message to
  contain "dataset create", but the duplicate-name error comes from
  LGBM_DatasetSetFeatureNames, so it could never match; and BulkPartitionTask
  sets the same names, so the retry would fail identically. It also mutated
  the estimator's dataTransferMode param mid-fit.
- Free the voidpp handle in createDatasetFromSamples, matching
  deserializeReferenceDataset. The previous code leaked it on every
  streaming fit().
- Skip feature names with a warning when their count does not match numCols,
  since LGBM_DatasetSetFeatureNames reads numCols entries.
- Revert stray setUseBarrierExecutionMode(true) in the ranker and regressor
  test data base classes; they are EstimatorFuzzing bases, so the flag
  applied to every ranker and regressor test.

Tests: VerifyLightGBMCommon 9/9, regressor/ranker stream 26/26, bulk and
network suites 52/52, scalastyle clean.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…ame message

Addresses two review comments.

createReferenceDatasetFromSample allocated the native Dataset and only freed
it on the success path, inside serializeAndCleanup. Naming the Dataset added a
new throwing call between the allocation and that free, so a duplicate or
invalid feature name leaked the Dataset. The handle is now freed in a finally
that covers both naming and serialization, and serializeAndCleanup becomes
serializeReference since it no longer owns cleanup. The free is intentionally
not validated: throwing from the finally would mask the original failure.

The invalid slot name message listed backslash as a rejected character, but the
regex never matched it and LightGBM does not reject it either. Its CheckAllowedJSON
rejects exactly " , : [ ] { }. The message now matches both the regex and the
native behavior. The regex is deliberately unchanged; adding backslash to it
would reject names LightGBM accepts.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review details

Suppressed comments (1)

lightgbm/src/main/scala/com/microsoft/azure/synapse/ml/lightgbm/LightGBMBase.scala:211

  • getSlotNamesWithMetadata assumes every Attribute entry is non-null. Spark attribute metadata can contain null slots (this file already handles case (null, _) in getCategoricalIndexes), and a null here would NPE during name extraction and break training.

Guard null attributes and fall back to the default index-based name when an entry is null.

          val colNames = attributes.indices.map(_.toString).toArray
          attributes.foreach(attr =>
            attr.index.foreach(index => colNames(index) = attr.name.getOrElse(index.toString)))
          // Ensure unique feature names to avoid LightGBM error:
  • Files reviewed: 3/3 changed files
  • Comments generated: 1
  • Review effort level: Lite

@ranadeepsingh

Copy link
Copy Markdown
Collaborator Author

/azp run

@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
Successfully started running 1 pipeline(s).

Review feedback: the length guard added for the reference dataset only covered
one of four call sites. LGBM_DatasetSetFeatureNames reads numCols entries from
the array, and slotNames is user-supplied and never length-checked upstream, so
the bulk and streaming per-partition paths could still pass a short array into
native code.

Verified rather than assumed. With the guard reverted, both new tests fail:

  Dataset set feature names call failed in LightGBM with error:
  basic_string: construction from null is not valid

The native read runs off the end of the Java String[], reads null, and
std::string construction from null throws -- crashing the executor task and
failing the whole fit. It reproduces in both streaming and bulk transfer modes.

Move the guard into LightGBMDataset.setFeatureNames so all naming paths are
protected, and add regression tests for both transfer modes.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings August 12, 2026 02:35
@ranadeepsingh

Copy link
Copy Markdown
Collaborator Author

Good catch — you were right, and it was worse than a theoretical risk. Fixed by centralizing the guard in LightGBMDataset.setFeatureNames as you suggested.

I reverted the guard and ran the new tests to confirm the exposure is real rather than assume it:

\
java.lang.Exception: Dataset set feature names call failed in LightGBM with error:
basic_string: construction from null is not valid
\\

LGBM_DatasetSetFeatureNames reads numCols entries, runs off the end of the Java String[], gets null, and std::string construction from null throws — killing the executor task and failing the whole fit. It reproduces in both streaming and bulk transfer modes, exactly the paths you identified. slotNames is user-supplied and never length-checked upstream (validateSlotNames only screens for special characters), so any user passing a mismatched length hits this.

Changes:

  • Length guard moved into LightGBMDataset.setFeatureNames, covering BulkPartitionTask, StreamingPartitionTask, and ReferenceDatasetUtils.createReferenceDatasetFromSample.
  • ReferenceDatasetUtils.setFeatureNamesIfProvided keeps its own guard since it operates on a raw SWIGTYPE_p_void handle and cannot delegate.
  • Two regression tests, one per transfer mode.

Validation: VerifyLightGBMCommon 11/11 pass with the guard, 2 fail without it. lightgbm/scalastyle and lightgbm/Test/scalastyle clean.

/azp run

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review details

Suppressed comments (2)

lightgbm/src/main/scala/com/microsoft/azure/synapse/ml/lightgbm/dataset/LightGBMDataset.scala:186

  • The code skips naming when featureNamesArray.length != numCols, but the preceding comment only explains the short-array risk. Since the condition also skips longer arrays, the comment should be updated to reflect the full mismatch behavior to avoid confusion.
        // LGBM_DatasetSetFeatureNames reads numCols entries from the array, so a shorter array
        // is an out-of-bounds native read. slotNames is user-supplied and unvalidated, so guard
        // every dataset-naming path here rather than at individual call sites. LightGBM falls
        // back to its own generated names when naming is skipped.

lightgbm/src/main/scala/com/microsoft/azure/synapse/ml/lightgbm/dataset/ReferenceDatasetUtils.scala:80

  • The guard skips naming when names.length != numCols, but the comment only justifies the short-array case. This is misleading for future maintainers (the current condition also skips when the array is longer than numCols). Update the comment to describe the full mismatch behavior and rationale.
      if (names.length != numCols) {
        // LGBM_DatasetSetFeatureNames reads numCols entries from the array, so a shorter array
        // would be an out-of-bounds native read. Skip naming rather than risk it; LightGBM then
        // falls back to its own generated names, which is the behavior prior to this feature.
  • Files reviewed: 4/4 changed files
  • Comments generated: 0 new
  • Review effort level: Lite

@ranadeepsingh

Copy link
Copy Markdown
Collaborator Author

/azp run

@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
Successfully started running 1 pipeline(s).

@ranadeepsingh
Rana Singh (ranadeepsingh) merged commit 2c21cf2 into microsoft:master Aug 12, 2026
76 checks passed
@ranadeepsingh Rana Singh (ranadeepsingh) changed the title fix: LightGBM Ensure non-duplicate column names fix(lightgbm): de-duplicate feature names so training no longer fails on repeated columns Aug 12, 2026
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.

[BUG] Error while using "LightGMB" on Fabric

4 participants