Skip to content

feat: add persisted top-K categorical lumping - #2596

Open
ranadeepsingh wants to merge 4 commits into
microsoft:masterfrom
ranadeepsingh:copilot/ancient-1941-lump-features
Open

feat: add persisted top-K categorical lumping#2596
ranadeepsingh wants to merge 4 commits into
microsoft:masterfrom
ranadeepsingh:copilot/ancient-1941-lump-features

Conversation

@ranadeepsingh

Copy link
Copy Markdown
Collaborator

Summary

Recreates the valuable categorical-lumping proposal from #1941 for the current SynapseML codebase and addresses #1891.

  • implements LumpFeatures as a Spark ML Estimator plus persisted LumpFeaturesModel
  • preserves the multi-column lumpRules API, including Scala map, Java map, legacy JSON, and generated Python dict bindings
  • learns top-K values once during fit; transform never refits
  • ranks deterministically by count descending and value ascending
  • retains learned values, maps rare and unseen non-null values to an explicit otherValue, and makes null handling configurable
  • rejects real-data collisions with the other bucket
  • validates StringType-only v1 schemas and handles literal dotted, backtick, and count column names safely
  • persists learned state and supports estimator/model/pipeline serialization and copy behavior

This is a new implementation; it does not alter the ancient PR branch.

Validation

  • core/compile
  • core/testOnly com.microsoft.azure.synapse.ml.stages.LumpFeaturesSuite — 18 tests passed
  • core/scalastyle; core/Test/scalastyle
  • codegen
  • generated LumpFeatures.py and LumpFeaturesModel.py syntax-compiled with py_compile
  • final SynapseML code review: no concrete issues

Design notes

The initial API intentionally supports string columns only so the explicit other bucket cannot silently change numeric or boolean column types. Nulls are excluded from frequency counts and preserved by default; unseen non-null values always use the other bucket. Fitting fails if otherValue already occurs in any configured column, avoiding ambiguous category merges.

Related: #1941, #1891

Copilot AI review requested due to automatic review settings August 1, 2026 15:02
@github-actions

github-actions Bot commented Aug 1, 2026

Copy link
Copy Markdown

Hey @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:
There may be pipelines that require an authorized user to comment /azp run to run.

@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

Adds a new core Spark ML stage to address high-cardinality categorical features by learning per-column top‑K string values at fit time and applying deterministic lumping at transform time, with full persistence/serialization support consistent with SynapseML’s Estimator/Model patterns.

Changes:

  • Introduces LumpFeatures (Estimator) and LumpFeaturesModel (Model) with multi-column lumpRules, deterministic top‑K selection, configurable null handling, and otherValue collision validation.
  • Implements persisted learned state via keptValuesJson and supports load/save, copy, and schema transformation behavior.
  • Adds a comprehensive Scala test suite covering ranking, null handling, collisions, schema/metadata behavior, special column names, and persistence round-trips.
Show a summary per file
File Description
core/src/main/scala/com/microsoft/azure/synapse/ml/stages/LumpFeatures.scala New Estimator/Model implementation for persisted top‑K categorical lumping with deterministic ranking, schema validation, and serialization.
core/src/test/scala/com/microsoft/azure/synapse/ml/stages/LumpFeaturesSuite.scala New unit tests validating behavior, edge cases, schema expectations, and persistence round-trip.

Review details

Suppressed comments (1)

core/src/main/scala/com/microsoft/azure/synapse/ml/stages/LumpFeatures.scala:227

  • handleNull='keep' currently only preserves nulls when the input schema marks the column as nullable; otherwise the expression coalesces nulls to otherValue. This makes runtime behavior depend on schema nullability and can violate the documented semantics that 'keep' preserves null. Refactor lumpExpr to preserve nulls whenever keepNulls is true, independent of schema nullability, and drop the inputNullable parameter.
      transformSchema(dataset.schema)
      val other = getOtherValue
      val keepNulls = getHandleNull == "keep"
      getKeptValues.foldLeft(dataset.toDF()) { case (acc, (name, values)) =>
        acc.withColumn(name, lumpExpr(name, values, other, keepNulls, acc.schema(name).nullable))
  • Files reviewed: 2/2 changed files
  • Comments generated: 1
  • Review effort level: Lite

val keepNulls = getHandleNull == "keep"
val fields = schema.fields.map { f =>
if (ruleCols.contains(f.name)) {
StructField(f.name, StringType, nullable = f.nullable && keepNulls, Metadata.empty)
@codecov-commenter

codecov-commenter commented Aug 1, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 96.37306% with 7 lines in your changes missing coverage. Please review.
✅ Project coverage is 84.80%. Comparing base (d6fa6c4) to head (a676184).
⚠️ Report is 1 commits behind head on master.

Files with missing lines Patch % Lines
...crosoft/azure/synapse/ml/stages/LumpFeatures.scala 96.37% 7 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master    #2596      +/-   ##
==========================================
+ Coverage   83.60%   84.80%   +1.20%     
==========================================
  Files         334      336       +2     
  Lines       17806    18075     +269     
  Branches     1623     1633      +10     
==========================================
+ Hits        14887    15329     +442     
+ Misses       2919     2746     -173     

☔ 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 added a commit to ranadeepsingh/SynapseML that referenced this pull request Aug 1, 2026
## Summary
Add dedicated transformer fuzzing coverage for LumpFeaturesModel so global experiment, serialization, Python, and R coverage gates recognize the persisted model.

## Prompting Intent
Repair the concrete UnitTests core failure from PR microsoft#2596 after Azure build 229219360 reported that LumpFeaturesModel had no directly registered fuzzers, while preserving all estimator tests.

## Linked Sources
- Pull request: microsoft#2596
- Failed Azure build: https://msdata.visualstudio.com/b9b2accc-2d1c-45b3-9d24-0eb5d78cc47f/_build/results?buildId=229219360
- Original proposal: microsoft#1941
- Feature request: microsoft#1891

## Rationale
Register a real TransformerFuzzing test object instead of exempting the model. This exercises deterministic transforms and model persistence while generating Python and R correspondence coverage expected by the repository-wide FuzzingTest.

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

Copy link
Copy Markdown
Collaborator Author

/azp run

@azure-pipelines

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

ranadeepsingh added a commit to ranadeepsingh/SynapseML that referenced this pull request Aug 1, 2026
## Summary
Persist each fitted top-K alongside retained values and reject incompatible LumpFeaturesModel lumpRules mutations across direct setters, generic generated-binding transfer, copy overrides, and loaded models.

## Prompting Intent
Address the independent medium-severity API review finding on PR microsoft#2596 without removing API-compatible params. Ensure a fitted model can never silently score with learned values that disagree with a post-fit K, and cover persistence, copy, Scala, Java, JSON, and generated-binding paths.

## Linked Sources
- Pull request: microsoft#2596
- Original proposal: microsoft#1941
- Feature request: microsoft#1891
- Independent review finding supplied in the PR follow-up request
- No Azure DevOps work item was supplied; tracking is through the linked GitHub issue.

## Rationale
Encode the fitted top-K inside the existing model-only keptValuesJson state instead of adding another generated mutable parameter. Direct model setters fail immediately, while transform-time state validation protects generic Param paths used by generated bindings. Exact no-op rule assignment remains allowed, and incompatible copy overrides fail before returning.

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

Copy link
Copy Markdown
Collaborator Author

Addressed the independent API review finding in 5443214:

  • fitted top-K is now persisted with retained values
  • Scala, Java, and JSON setLumpRules overloads reject incompatible post-fit changes immediately
  • generated-binding/generic Param mutations are rejected by model-state validation before scoring
  • incompatible copy(extra) overrides are rejected
  • persistence, loaded-model copy, no-op assignment, and mutation paths have regression coverage

Validation: 24 estimator tests, 4 model tests, 10 repository fuzzing checks, core compile/scalastyle, codegen, and generated-wrapper py_compile all passed.

@copilot-pull-request-reviewer please re-review the updated head.

@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

Follow-up API fix is now fully green on head 54432143d3:

  • all 76 GitHub/Azure checks passed
  • Azure build 229247329 succeeded
  • targeted estimator/model/fuzzing tests, scalastyle, codegen, and generated-wrapper syntax checks passed locally

Re-review requested for the fitted lumpRules immutability change.

ranadeepsingh and others added 4 commits August 4, 2026 00:41
## Summary
Add LumpFeatures as a Spark ML estimator with a persisted model, deterministic top-K learning, explicit other-bucket and null semantics, schema-safe transforms, generated bindings, and comprehensive tests.

## Prompting Intent
Recreate the valuable proposal from GitHub PR microsoft#1941 for current SynapseML without fitting during transform. Preserve lumpRules compatibility while covering persistence, copy behavior, special column names, unseen values, collisions, and Scala-first Python code generation.

## Linked Sources
- Original proposal PR: microsoft#1941
- Feature request: microsoft#1891
- No Azure DevOps work item was supplied; tracking is through the linked GitHub issue.

## Rationale
Learn category frequencies once in fit and persist only retained values so scoring is stable and side-effect free. Restrict v1 to string columns, rank ties by value, preserve nulls by default, and reject other-bucket collisions rather than silently merging real categories. Use Spark SQL expressions instead of UDFs and retain the multi-column lumpRules API.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
## Summary
Add dedicated transformer fuzzing coverage for LumpFeaturesModel so global experiment, serialization, Python, and R coverage gates recognize the persisted model.

## Prompting Intent
Repair the concrete UnitTests core failure from PR microsoft#2596 after Azure build 229219360 reported that LumpFeaturesModel had no directly registered fuzzers, while preserving all estimator tests.

## Linked Sources
- Pull request: microsoft#2596
- Failed Azure build: https://msdata.visualstudio.com/b9b2accc-2d1c-45b3-9d24-0eb5d78cc47f/_build/results?buildId=229219360
- Original proposal: microsoft#1941
- Feature request: microsoft#1891

## Rationale
Register a real TransformerFuzzing test object instead of exempting the model. This exercises deterministic transforms and model persistence while generating Python and R correspondence coverage expected by the repository-wide FuzzingTest.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
## Summary
Persist each fitted top-K alongside retained values and reject incompatible LumpFeaturesModel lumpRules mutations across direct setters, generic generated-binding transfer, copy overrides, and loaded models.

## Prompting Intent
Address the independent medium-severity API review finding on PR microsoft#2596 without removing API-compatible params. Ensure a fitted model can never silently score with learned values that disagree with a post-fit K, and cover persistence, copy, Scala, Java, JSON, and generated-binding paths.

## Linked Sources
- Pull request: microsoft#2596
- Original proposal: microsoft#1941
- Feature request: microsoft#1891
- Independent review finding supplied in the PR follow-up request
- No Azure DevOps work item was supplied; tracking is through the linked GitHub issue.

## Rationale
Encode the fitted top-K inside the existing model-only keptValuesJson state instead of adding another generated mutable parameter. Direct model setters fail immediately, while transform-time state validation protects generic Param paths used by generated bindings. Exact no-op rule assignment remains allowed, and incompatible copy overrides fail before returning.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Rework the persisted top-K lumping stage so it is dependable as a training
feature-engineering step, not just correct on a single-column happy path.

Algorithm
- Add minCount and minFreq eligibility filters, applied before the lumpRules
  top-K cap. This matches the order scikit-learn's OneHotEncoder uses for
  min_frequency and max_categories, and R forcats / feature-engine use the
  same primary-threshold + secondary-cap shape. Top-K alone is blind to the
  distribution: it lumps healthy levels in a low-cardinality column, and in a
  long-tailed column it retains values covering almost none of the rows.
  Both default to no-ops, so existing behaviour is unchanged.
- Rewrite fit as a single pass. The rule columns are melted into
  (column, value) pairs and ranked with one windowed aggregation instead of
  one full scan per column plus a separate collision scan. Besides cutting
  fit from N+1 Spark jobs to 1, this guarantees every column is learned from
  the same materialization of the input; per-column jobs silently learn from
  different rows when the upstream plan is non-deterministic (sample, rand,
  unordered limit).

API
- Add an optional outputCols map so lumped values can be written to new
  columns instead of destroying the raw ones. Unset means in-place, so the
  default is unchanged. Destinations are validated (known source, non-empty,
  distinct, not already in the input schema) and ordered deterministically so
  transformSchema always matches transform.
- Fix the nullability contract: the declared schema now derives nullability
  from handleNull alone instead of intersecting it with the input column's
  nullability, so a non-nullable input under handleNull='keep' no longer
  declares a non-nullable output that the expression may not honour. This is
  the reviewer comment on the PR; the transform expression uses a typed null
  literal so declared and actual schemas agree on non-nullable inputs too.
- Expose the learned values to Python. LumpFeaturesModel becomes an internal
  wrapper with a hand-written override providing getKeptValues(), backed by a
  new getKeptValuesAsJson on the Scala model, so a fit can be audited from
  Python instead of being an opaque JSON param.

Docs and tests
- Document LumpFeatures in docs/Quick Examples with runnable Python and Scala
  examples; the stage was previously absent from the stages doc table.
- Add 13 tests covering the frequency filters and their ordering against the
  cap, per-column denominators, joint vs single-column fit agreement,
  outputCols behaviour/validation/round-trip, the non-nullable schema
  contract, and the JSON accessor. 41 tests pass, scalastyle clean on main
  and test, codegen and black clean.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 3a108ab7-6879-4fa6-81de-ef2d43eb3ec5
@ranadeepsingh
ranadeepsingh force-pushed the copilot/ancient-1941-lump-features branch from 5443214 to a676184 Compare August 4, 2026 18:33
@ranadeepsingh

Copy link
Copy Markdown
Collaborator Author

/azp run

@azure-pipelines

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

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.

3 participants