fix(lightgbm): de-duplicate feature names so training no longer fails on repeated columns - #2508
Conversation
|
/azp run |
|
Hey Rana Singh (@ranadeepsingh) 👋! We use semantic commit messages to streamline the release process. Examples of commit messages with semantic prefixes:
To test your commit locally, please follow our guild on building from source. |
|
Azure Pipelines successfully started running 1 pipeline(s). |
Codecov Report✅ All modified and coverable lines are covered by tests. 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. 🚀 New features to boost your workflow:
|
|
/azp run |
|
Azure Pipelines successfully started running 1 pipeline(s). |
|
/azp run |
|
Azure Pipelines successfully started running 1 pipeline(s). |
|
/azp run |
|
Azure Pipelines successfully started running 1 pipeline(s). |
|
/azp run |
|
Azure Pipelines successfully started running 1 pipeline(s). |
|
/azp run |
|
Azure Pipelines successfully started running 1 pipeline(s). |
4cb326b to
1b74030
Compare
|
/azp run |
|
Azure Pipelines: Successfully started running 1 pipeline(s). |
There was a problem hiding this comment.
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
slotNamesandAttributeGroupmetadata) using LightGBM-style normalization rules. - Validate slot names earlier in
fitto 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
serializeAndCleanupfrees 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'sfinallyso 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
|
/azp run |
|
Azure Pipelines: Successfully started running 1 pipeline(s). |
There was a problem hiding this comment.
Review details
Suppressed comments (2)
lightgbm/src/main/scala/com/microsoft/azure/synapse/ml/lightgbm/LightGBMBase.scala:587
getSlotNamesWithMetadata(and thereforeensureUniqueFeatureNames) is invoked multiple times during a singlefitin streaming mode (e.g., once insidecalculateRowStatisticsand again when building theTrainingContextinexecuteTraining). SinceensureUniqueFeatureNameslogs 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
validateSlotNamescurrently validates the de-duplicated names returned bygetSlotNamesWithMetadata. If the input contains invalid characters and also has duplicates,ensureUniqueFeatureNamescan append a suffix (e.g.,bad,name_1), and the thrownIllegalArgumentExceptionwill list names the user never supplied, making the error harder to act on. Validate the raw names first (either explicitslotNamesor 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>
af6664d to
cd9560d
Compare
There was a problem hiding this comment.
Review details
Suppressed comments (1)
lightgbm/src/main/scala/com/microsoft/azure/synapse/ml/lightgbm/LightGBMBase.scala:211
getSlotNamesWithMetadataassumes everyAttributeentry is non-null. Spark attribute metadata can contain null slots (this file already handlescase (null, _)ingetCategoricalIndexes), 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
|
/azp run |
|
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>
|
Good catch — you were right, and it was worse than a theoretical risk. Fixed by centralizing the guard in I reverted the guard and ran the new tests to confirm the exposure is real rather than assume it: \
Changes:
Validation: /azp run |
There was a problem hiding this comment.
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 thannumCols). 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
|
/azp run |
|
Azure Pipelines: Successfully started running 1 pipeline(s). |
2c21cf2
into
microsoft:master
Why
Fixes #2242.
LightGBM refuses to build a Dataset whose feature names repeat. Spark can hand it repeated names in two ways — through the
AttributeGroupmetadata of the features column, or through a user-suppliedslotNames— and either one killed training: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
ensureUniqueFeatureNames, which suffixes repeats while preserving order (LightGBM feature names are positional). A warning lists what was renamed.createReferenceDatasetFromSamplenow takes the feature names and applies them, so the reference Dataset matches the per-partition Datasets. Split intocreateDatasetFromSamples/setFeatureNamesIfProvided/serializeAndCleanup.validateSlotNamespreviously ran only whenAttributeGroupmetadata was present, so explicitslotNameswere never checked.LGBM_DatasetSetFeatureNamescall earlier intrainOneDataBatchthan the old validation point, so validation moved up to wherefeaturesSchemaresolves.createDatasetFromSamplesfrees itsvoidpphandle in afinally; the referenceDatasetis freed in afinallyspanning 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.
["Column_", "Column_", "Column__1"]into a collision onColumn__1— the exact error this PR fixes. All original names are reserved up front."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.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 actionableIllegalArgumentException.Two smaller corrections: the invalid-name message listed
\as disallowed, but neither the regex nor LightGBM'sCheckAllowedJSONrejects it (which accepts exactly" , : [ ] { }) — the message now matches reality, and the regex is deliberately unchanged. And when the name count disagrees withnumCols, 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:BulkPartitionTaskcallssetFeatureNameswith 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
validateSlotNamesnow also checks explicitly suppliedslotNames. A job passingslotNamescontaining" , : [ ] { }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:Verify duplicate feature names are handled correctlyAttributeGroupmetadataVerify explicit slotNames parameter is usedslotNamesstill appliedVerify duplicate explicit slotNames are made uniqueslotNamesVerify a generated slot name cannot collide with a later original nameVerify names differing only by space vs underscore are made uniqueLocally on Java 11 with the full CI datasets:
VerifyLightGBMCommon9/9; the stream/bulk regressor, ranker and classifier suites plusNetworkManagerSuiteandTrainUtilsSuite52/52 (including…bad column names fails early);lightgbm/scalastyleandlightgbm/Test/scalastyle0 errors.Checklist