[ 
https://issues.apache.org/jira/browse/SPARK-59376?page=com.atlassian.jira.plugin.system.issuetabpanels:all-tabpanel
 ]

Varun Bhandary updated SPARK-59376:
-----------------------------------
    Description: 
+*Problem and intended users*+

Spark ML does not currently provide a built-in fitted encoder that maps 
categorical values to their training counts or proportions. This proposal adds 
FrequencyEncoder and FrequencyEncoderModel for users who want category 
prevalence as a numerical feature in workflows that train and score Spark 
DataFrames.

The feature represents how common a category was in the training reference 
data. It requires no label and can be considered as an additional feature for 
anomaly detection, clustering, or supervised prediction when category 
prevalence is relevant. It is not a general replacement for categorical 
identity: equally frequent categories receive the same value, and downstream 
usefulness depends on the task.

+*Established method and existing use*+

There is concrete use in the Spark ecosystem. DQX implements frequency encoding 
for categorical features selected as high cardinality in its anomaly-detection 
feature engineering. Its implementation learns proportions with Spark 
aggregation, retains the mappings for subsequent transformations, and supplies 
a zero fallback for unmatched categories. This is an existing example of an 
application maintaining the fitted-state behavior proposed here. [DQX encoding 
implementation|https://github.com/databrickslabs/dqx/blob/a5b78de562408040c1a3dda2776009229eeaa68c/src/databricks/labs/dqx/anomaly/transformers.py#L559],
 [DQX categorical-feature 
selection|https://github.com/databrickslabs/dqx/blob/a5b78de562408040c1a3dda2776009229eeaa68c/src/databricks/labs/dqx/anomaly/transformers.py#L610].

+The method also has implementations outside this application:+
 * Category Encoders exposes CountEncoder, with normalization available to 
obtain proportions. [CountEncoder 
API|https://contrib.scikit-learn.org/category_encoders/count.html]
 * Feature-engine exposes CountFrequencyEncoder with count and frequency modes, 
learned mappings, and configurable unseen-category behavior. 
[CountFrequencyEncoder 
API|https://feature-engine.trainindata.com/en/latest/api_doc/encoding/CountFrequencyEncoder.html].

+There is academic coverage as well.+

Matteucci, Arzamasov, and Böhm's *A benchmark of categorical encoders for 
binary classification* (NeurIPS 2023, Datasets and Benchmarks) includes Count 
in its evaluation and describes both count and normalized frequency encodings 
in Table 7. This supports recognition of the method in the literature; it is 
not evidence of universal superiority or a benchmark of this Spark 
implementation. [Published 
paper|https://papers.neurips.cc/paper_files/paper/2023/hash/ac01e21bb14609416760f790dd8966ae-Abstract-Datasets_and_Benchmarks.html],
 [[paper text, Section 3.2 and Table 7]|https://arxiv.org/pdf/2307.09191].

These references address MLlib's expectations of an established method with 
concrete use cases. DQX provides the Spark-specific example; the two libraries 
and paper show that the operation is used and studied independently of this 
proposal. They do not imply that those projects have committed to adopting the 
new Spark API.

+*Concrete behavior*+

Consider training data containing category A eight times and category B twice. 
The normalized fitted mapping is A → 0.8 and B → 0.2. A subsequent scoring 
batch containing only B must still encode B as 0.2. Computing frequencies over 
that scoring batch would instead produce 1.0 and change the meaning of the 
trained feature. A previously unseen category follows the configured 
invalid-category policy.

This illustrates the required contract: learn the reference distribution during 
fit, then reuse it unchanged during transform. Changing batch size, row order, 
partitioning, or the other categories present in a scoring batch must not 
change a row's encoding for a fixed fitted model.

+*Why an Estimator and Model in spark.ml*+

The intended workflow is:

*StringIndexer → FrequencyEncoder → VectorAssembler → downstream estimator*

Keeping these stages in one Pipeline would provide three practical benefits:
 # When the whole Pipeline is passed to CrossValidator or TrainValidationSplit, 
each training split learns its own index and frequency mappings. Under an 
ordinary inductive evaluation protocol, held-out rows should not contribute to 
these fitted statistics. Fitting the encoder before splitting would bypass that 
boundary even though the encoder does not use labels. Spark already supports 
fitting complete pipelines within its tuning tools. [ML tuning 
documentation]([https://spark.apache.org/docs/latest/ml-tuning.html).]
 # The fitted index mapping, frequency mapping, and downstream model can be 
saved together and restored together. In particular, a frequency model must not 
accidentally be paired with a newly fitted StringIndexer that assigns different 
category indices. This follows the existing Spark ML persistence model. [ML 
Pipeline 
documentation]([https://spark.apache.org/docs/latest/ml-pipeline.html#ml-persistence-saving-and-loading-pipelines)]
 # Spark users would have a documented contract for invalid categories, nulls, 
normalization, schema inference, and supported language and Connect interfaces, 
instead of maintaining these decisions separately in every application.

IDF is a useful precedent: it learns corpus statistics without labels and 
applies the fitted statistics to subsequent data. FrequencyEncoder proposes a 
similar fit/transform lifecycle for categorical occurrence frequencies. This is 
an analogy about model state, not a claim that the two encodings have the same 
statistical meaning. [IDF 
documentation]([https://spark.apache.org/docs/latest/api/python/reference/api/pyspark.ml.feature.IDF.html).]
 

+*Existing alternatives and the case for inclusion*+
 # Spark SQL can compute the counts and apply them with a join. A versioned 
lookup table and a custom Estimator can provide the same semantics. 
SQLTransformer can execute the transformation query, {+}but does not itself 
learn and own a frequency mapping during fit{+}; that state must be supplied 
through another mechanism. [SQLTransformer 
documentation]([https://spark.apache.org/docs/latest/api/python/reference/api/pyspark.ml.feature.SQLTransformer.html).]
 # A scikit-learn-compatible encoder is also a reasonable choice for local 
workflows, as the implementations above demonstrate. The proposed Spark stage 
would serve users who want to perform the fitted transformation within their 
existing Spark DataFrame and Pipeline workflow.

The proposed benefit of including this in spark.ml is a standard fitted 
component for existing Spark ML workflows, with common persistence and API 
behavior and no additional algorithm dependency. An external Spark package 
remains technically viable. The inclusion decision should depend on 
demonstrated reuse and the cost of maintaining the public API, rather than on 
whether SQL or Python can express the arithmetic.

Sampling remains valid when estimated proportions are sufficient. DQX's 
training path samples before fitting, so its implementation should not be cited 
as evidence that useful frequency encoding requires every original row. The 
fit/transform contract applies to whichever training DataFrame the caller 
selects. [DQX training 
workflow]([https://github.com/databrickslabs/dqx/blob/a5b78de562408040c1a3dda2776009229eeaa68c/src/databricks/labs/dqx/anomaly/training_service.py#L310).]
 

+*Proposed API*+
 * FrequencyEncoder is an Estimator that produces FrequencyEncoderModel.
 * inputCol/outputCol and inputCols/outputCols select independently encoded 
feature pairs.
 * normalize defaults to true for proportions; false selects raw counts.
 * handleInvalid supports error and keep; keep assigns zero to unseen 
categories.
 * Numeric category indices compose with StringIndexer for string inputs.
 * The fitted model participates in standard Pipeline persistence. Scala, Java, 
Python, and Connect behavior must be covered by the implementation's 
integration tests.

The interface follows the nearby TargetEncoder where the semantics align. 
FrequencyEncoder needs no label, and its output is a numerical prevalence 
feature. The proposal preserves the documented behavior that equally frequent 
categories collapse to the same encoding.

+*Scope and scale*+

The initial proposal provides counts or normalized proportions over the 
DataFrame supplied to fit, including a sample if the caller chooses one. It 
provides independent encodings for multiple input columns and defined behavior 
for unseen and invalid values. It does not introduce automatic sampling, online 
updates, time-windowed statistics, or a claim of superior model accuracy.

Distributed aggregation is useful when the chosen training DataFrame is large. 
The current implementation also retains state proportional to the total number 
of distinct categories across the input columns, collects that state to the 
driver, and embeds lookup maps in transformation plans. Consequently, large row 
counts and large category counts are separate scaling concerns. The proposal 
must document measured cardinality and memory limits; it should not promise 
unrestricted high-cardinality support.

+*Acceptance criteria*+
 # A complete Pipeline containing indexing, frequency encoding, assembly, and a 
downstream estimator fits and transforms successfully, with schema inference 
matching actual output.
 # A validation test proves that each fold learns only from its training 
portion. A scoring test proves that the same fitted category receives the same 
encoding alone and in differently composed batches.
 # Saving and loading the complete PipelineModel preserves mappings and 
predictions, including multiple columns and reordered persistence records. 
Supported cross-language and Connect paths are exercised.
 # Nulls, unseen values, empty input, numeric precision, and normalization have 
documented behavior and discriminating regression tests.
 # Benchmarks report row count, category count per feature, skew, partition 
count, heap size, fit and transform time, and model/plan size. Comparisons 
include a correct SQL aggregation-and-lookup baseline, including repeated 
fitting where relevant.

I am willing to maintain this component, respond to review, and address 
regressions. Maintainer feedback on inclusion in ml.feature versus an external 
Spark package would help settle the appropriate scope before adding further 
functionality.

  was:
MLlib has no unsupervised encoder for high cardinality categorical features.

StringIndexer produces ordinals whose magnitude means nothing to a model that 
reads
its features as numbers. OneHotEncoder is correct but adds a column per 
category,
which stops being practical after a few hundred. FeatureHasher avoids the width 
by
accepting collisions and losing interpretability. TargetEncoder, added in 4.0.0 
by
SPARK-37178, needs a label column, so it is unavailable for unsupervised work
entirely.

That leaves clustering, anomaly detection and dimensionality reduction with no 
good
option for a column of fifty thousand merchant ids. That is a gap in the basics
rather than a missing exotic algorithm.

Frequency encoding fills it. Each category is replaced by how often it occurs 
in the
training data, as a proportion of the training rows or as a raw count. No label 
is
needed.

Why this belongs in Spark rather than in a local library

Fitting a frequency encoder is a full-data aggregation. A category's encoding 
is its
count over every row, so unlike a sampling estimator there is no useful answer 
to be
had from a subset. That is the work a groupBy does well: counting combines on 
the map
side, so only per-partition per-category counts cross the network, and the 
result is
one row per category however many rows went in. The physical plan for the shape 
fit
uses, measured on a 100,000 row check that reduces to 5,000 result rows:

  HashAggregate(keys=[index, value], functions=[partial_count(1)])
  Exchange hashpartitioning(index, value, 200)
  HashAggregate(keys=[index, value], functions=[count(1)])

The reduction happens before the shuffle rather than after it. Computing the 
same
encoding outside Spark means bringing the whole column to a single machine 
first,
which is the cost this avoids, and which grows with the data while the answer 
does
not.

This is the same shape as TargetEncoder, whose fit is also a per-category 
aggregation
over every row. It is worth contrasting with an estimator that genuinely does 
not
need distributing: a sampling method that trains on a few hundred rows gains 
nothing
from a cluster, whereas here the full pass is the computation.

As one concrete instance of the gap, DQX (https://github.com/databrickslabs/dqx)
implements frequency encoding itself for the categorical features feeding its 
anomaly
detection, because ml.feature has no equivalent to call.

Proposed API, mirroring TargetEncoder so the two stay consistent

  FrequencyEncoder, an Estimator, producing FrequencyEncoderModel
  inputCol / outputCol and inputCols / outputCols, pairs treated independently
  handleInvalid, error or keep, where keep encodes unseen categories as zero
  normalize, default true for a proportion, false for the raw count
  numeric already-indexed input, as TargetEncoder requires, so StringIndexer 
feeds it

transform applies a map holding one entry per category with try_element_at 
against a
literal, so encoding is a per-row projection with no join and no shuffle.

It is fair to say a user can write groupBy.count and a join themselves. The 
same is
true of TargetEncoder, and in both cases the value is not the arithmetic. It is 
the
fitted model semantics: the mapping is learned once and applied identically at
training and serving time, it survives save and load, it composes in a 
Pipeline, and
unseen categories get defined behaviour instead of silently becoming null.

One property is worth stating up front rather than leaving to be discovered.
Categories occurring equally often receive the same encoding. That is inherent 
to the
technique, so it is documented on the class, in ml-features.md, and asserted in 
the
suite so it is not later mistaken for a bug.


> Add frequency encoding to ml.feature
> ------------------------------------
>
>                 Key: SPARK-59376
>                 URL: https://issues.apache.org/jira/browse/SPARK-59376
>             Project: Spark
>          Issue Type: New Feature
>          Components: ML
>    Affects Versions: 5.0.0
>            Reporter: Varun Bhandary
>            Priority: Major
>              Labels: pull-request-available
>
> +*Problem and intended users*+
> Spark ML does not currently provide a built-in fitted encoder that maps 
> categorical values to their training counts or proportions. This proposal 
> adds FrequencyEncoder and FrequencyEncoderModel for users who want category 
> prevalence as a numerical feature in workflows that train and score Spark 
> DataFrames.
> The feature represents how common a category was in the training reference 
> data. It requires no label and can be considered as an additional feature for 
> anomaly detection, clustering, or supervised prediction when category 
> prevalence is relevant. It is not a general replacement for categorical 
> identity: equally frequent categories receive the same value, and downstream 
> usefulness depends on the task.
> +*Established method and existing use*+
> There is concrete use in the Spark ecosystem. DQX implements frequency 
> encoding for categorical features selected as high cardinality in its 
> anomaly-detection feature engineering. Its implementation learns proportions 
> with Spark aggregation, retains the mappings for subsequent transformations, 
> and supplies a zero fallback for unmatched categories. This is an existing 
> example of an application maintaining the fitted-state behavior proposed 
> here. [DQX encoding 
> implementation|https://github.com/databrickslabs/dqx/blob/a5b78de562408040c1a3dda2776009229eeaa68c/src/databricks/labs/dqx/anomaly/transformers.py#L559],
>  [DQX categorical-feature 
> selection|https://github.com/databrickslabs/dqx/blob/a5b78de562408040c1a3dda2776009229eeaa68c/src/databricks/labs/dqx/anomaly/transformers.py#L610].
> +The method also has implementations outside this application:+
>  * Category Encoders exposes CountEncoder, with normalization available to 
> obtain proportions. [CountEncoder 
> API|https://contrib.scikit-learn.org/category_encoders/count.html]
>  * Feature-engine exposes CountFrequencyEncoder with count and frequency 
> modes, learned mappings, and configurable unseen-category behavior. 
> [CountFrequencyEncoder 
> API|https://feature-engine.trainindata.com/en/latest/api_doc/encoding/CountFrequencyEncoder.html].
> +There is academic coverage as well.+
> Matteucci, Arzamasov, and Böhm's *A benchmark of categorical encoders for 
> binary classification* (NeurIPS 2023, Datasets and Benchmarks) includes Count 
> in its evaluation and describes both count and normalized frequency encodings 
> in Table 7. This supports recognition of the method in the literature; it is 
> not evidence of universal superiority or a benchmark of this Spark 
> implementation. [Published 
> paper|https://papers.neurips.cc/paper_files/paper/2023/hash/ac01e21bb14609416760f790dd8966ae-Abstract-Datasets_and_Benchmarks.html],
>  [[paper text, Section 3.2 and Table 7]|https://arxiv.org/pdf/2307.09191].
> These references address MLlib's expectations of an established method with 
> concrete use cases. DQX provides the Spark-specific example; the two 
> libraries and paper show that the operation is used and studied independently 
> of this proposal. They do not imply that those projects have committed to 
> adopting the new Spark API.
> +*Concrete behavior*+
> Consider training data containing category A eight times and category B 
> twice. The normalized fitted mapping is A → 0.8 and B → 0.2. A subsequent 
> scoring batch containing only B must still encode B as 0.2. Computing 
> frequencies over that scoring batch would instead produce 1.0 and change the 
> meaning of the trained feature. A previously unseen category follows the 
> configured invalid-category policy.
> This illustrates the required contract: learn the reference distribution 
> during fit, then reuse it unchanged during transform. Changing batch size, 
> row order, partitioning, or the other categories present in a scoring batch 
> must not change a row's encoding for a fixed fitted model.
> +*Why an Estimator and Model in spark.ml*+
> The intended workflow is:
> *StringIndexer → FrequencyEncoder → VectorAssembler → downstream estimator*
> Keeping these stages in one Pipeline would provide three practical benefits:
>  # When the whole Pipeline is passed to CrossValidator or 
> TrainValidationSplit, each training split learns its own index and frequency 
> mappings. Under an ordinary inductive evaluation protocol, held-out rows 
> should not contribute to these fitted statistics. Fitting the encoder before 
> splitting would bypass that boundary even though the encoder does not use 
> labels. Spark already supports fitting complete pipelines within its tuning 
> tools. [ML tuning 
> documentation]([https://spark.apache.org/docs/latest/ml-tuning.html).]
>  # The fitted index mapping, frequency mapping, and downstream model can be 
> saved together and restored together. In particular, a frequency model must 
> not accidentally be paired with a newly fitted StringIndexer that assigns 
> different category indices. This follows the existing Spark ML persistence 
> model. [ML Pipeline 
> documentation]([https://spark.apache.org/docs/latest/ml-pipeline.html#ml-persistence-saving-and-loading-pipelines)]
>  # Spark users would have a documented contract for invalid categories, 
> nulls, normalization, schema inference, and supported language and Connect 
> interfaces, instead of maintaining these decisions separately in every 
> application.
> IDF is a useful precedent: it learns corpus statistics without labels and 
> applies the fitted statistics to subsequent data. FrequencyEncoder proposes a 
> similar fit/transform lifecycle for categorical occurrence frequencies. This 
> is an analogy about model state, not a claim that the two encodings have the 
> same statistical meaning. [IDF 
> documentation]([https://spark.apache.org/docs/latest/api/python/reference/api/pyspark.ml.feature.IDF.html).]
>  
> +*Existing alternatives and the case for inclusion*+
>  # Spark SQL can compute the counts and apply them with a join. A versioned 
> lookup table and a custom Estimator can provide the same semantics. 
> SQLTransformer can execute the transformation query, {+}but does not itself 
> learn and own a frequency mapping during fit{+}; that state must be supplied 
> through another mechanism. [SQLTransformer 
> documentation]([https://spark.apache.org/docs/latest/api/python/reference/api/pyspark.ml.feature.SQLTransformer.html).]
>  # A scikit-learn-compatible encoder is also a reasonable choice for local 
> workflows, as the implementations above demonstrate. The proposed Spark stage 
> would serve users who want to perform the fitted transformation within their 
> existing Spark DataFrame and Pipeline workflow.
> The proposed benefit of including this in spark.ml is a standard fitted 
> component for existing Spark ML workflows, with common persistence and API 
> behavior and no additional algorithm dependency. An external Spark package 
> remains technically viable. The inclusion decision should depend on 
> demonstrated reuse and the cost of maintaining the public API, rather than on 
> whether SQL or Python can express the arithmetic.
> Sampling remains valid when estimated proportions are sufficient. DQX's 
> training path samples before fitting, so its implementation should not be 
> cited as evidence that useful frequency encoding requires every original row. 
> The fit/transform contract applies to whichever training DataFrame the caller 
> selects. [DQX training 
> workflow]([https://github.com/databrickslabs/dqx/blob/a5b78de562408040c1a3dda2776009229eeaa68c/src/databricks/labs/dqx/anomaly/training_service.py#L310).]
>  
> +*Proposed API*+
>  * FrequencyEncoder is an Estimator that produces FrequencyEncoderModel.
>  * inputCol/outputCol and inputCols/outputCols select independently encoded 
> feature pairs.
>  * normalize defaults to true for proportions; false selects raw counts.
>  * handleInvalid supports error and keep; keep assigns zero to unseen 
> categories.
>  * Numeric category indices compose with StringIndexer for string inputs.
>  * The fitted model participates in standard Pipeline persistence. Scala, 
> Java, Python, and Connect behavior must be covered by the implementation's 
> integration tests.
> The interface follows the nearby TargetEncoder where the semantics align. 
> FrequencyEncoder needs no label, and its output is a numerical prevalence 
> feature. The proposal preserves the documented behavior that equally frequent 
> categories collapse to the same encoding.
> +*Scope and scale*+
> The initial proposal provides counts or normalized proportions over the 
> DataFrame supplied to fit, including a sample if the caller chooses one. It 
> provides independent encodings for multiple input columns and defined 
> behavior for unseen and invalid values. It does not introduce automatic 
> sampling, online updates, time-windowed statistics, or a claim of superior 
> model accuracy.
> Distributed aggregation is useful when the chosen training DataFrame is 
> large. The current implementation also retains state proportional to the 
> total number of distinct categories across the input columns, collects that 
> state to the driver, and embeds lookup maps in transformation plans. 
> Consequently, large row counts and large category counts are separate scaling 
> concerns. The proposal must document measured cardinality and memory limits; 
> it should not promise unrestricted high-cardinality support.
> +*Acceptance criteria*+
>  # A complete Pipeline containing indexing, frequency encoding, assembly, and 
> a downstream estimator fits and transforms successfully, with schema 
> inference matching actual output.
>  # A validation test proves that each fold learns only from its training 
> portion. A scoring test proves that the same fitted category receives the 
> same encoding alone and in differently composed batches.
>  # Saving and loading the complete PipelineModel preserves mappings and 
> predictions, including multiple columns and reordered persistence records. 
> Supported cross-language and Connect paths are exercised.
>  # Nulls, unseen values, empty input, numeric precision, and normalization 
> have documented behavior and discriminating regression tests.
>  # Benchmarks report row count, category count per feature, skew, partition 
> count, heap size, fit and transform time, and model/plan size. Comparisons 
> include a correct SQL aggregation-and-lookup baseline, including repeated 
> fitting where relevant.
> I am willing to maintain this component, respond to review, and address 
> regressions. Maintainer feedback on inclusion in ml.feature versus an 
> external Spark package would help settle the appropriate scope before adding 
> further functionality.



--
This message was sent by Atlassian Jira
(v8.20.10#820010)

---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to