This is an automated email from the ASF dual-hosted git repository.

zhengruifeng pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/spark.git


The following commit(s) were added to refs/heads/master by this push:
     new 6313ea6f804b [SPARK-57860][ML][PYTHON] Add HasIntermediateStorageLevel 
shared param and apply to KMeans
6313ea6f804b is described below

commit 6313ea6f804b981338c0da0218ca3480574cfb15
Author: Mao Li <[email protected]>
AuthorDate: Thu Jul 2 09:40:47 2026 +0800

    [SPARK-57860][ML][PYTHON] Add HasIntermediateStorageLevel shared param and 
apply to KMeans
    
    ### What changes were proposed in this pull request?
    
    This is the first sub-task of 
[SPARK-47103](https://issues.apache.org/jira/browse/SPARK-47103), which aims to 
make the default storage level of MLlib's intermediate datasets configurable.
    
    This PR:
    1. Adds a new shared param `HasIntermediateStorageLevel` (default 
`"MEMORY_AND_DISK"`, cannot be `"NONE"`) by extending the code generators on 
both sides:
       - Scala: `SharedParamsCodeGen.scala` -> regenerated `sharedParams.scala`
       - Python: `_shared_params_code_gen.py` -> regenerated `shared.py`
    2. Applies it to `KMeans` (Scala and PySpark): mixes in the trait, adds 
`setIntermediateStorageLevel`, and uses `$(intermediateStorageLevel)` at the 
`persist` call site instead of the hardcoded `StorageLevel.MEMORY_AND_DISK`.
    3. Adds test coverage in `KMeansSuite`.
    
    The design follows the suggestion from zhengruifeng on the earlier PR 
#45182 (a per-estimator param via a shared `HasIntermediateStorageLevel` trait, 
consistent with ALS's existing `intermediateStorageLevel`), rather than the 
global SQL config explored there. The remaining estimators are tracked as 
sibling sub-tasks under SPARK-47103.
    
    ### Why are the changes needed?
    
    MLlib persists *intermediate* datasets internally during training (e.g. 
blockified instances), with the storage level hardcoded to `MEMORY_AND_DISK`. 
These datasets are created inside the algorithm and are not the user's input 
`DataFrame`, so users currently have **no way** to change their storage level 
-- unlike the input, which they can already cache themselves.
    
    Making this configurable (e.g. `DISK_ONLY`) improves resilience to executor 
loss: since SPARK-27677, the External Shuffle Service can serve disk-persisted 
cached blocks, so disk-based intermediate storage survives executor failures. 
`ALS` already exposes exactly this via `intermediateStorageLevel`; this PR 
starts extending the same capability to the rest of MLlib.
    
    ### Does this PR introduce _any_ user-facing change?
    
    Yes. `KMeans` gains a new expert param `intermediateStorageLevel` and a 
`setIntermediateStorageLevel` setter.
    
    The default is `"MEMORY_AND_DISK"`, so **behavior is unchanged unless the 
user sets it**.
    
    Before (no way to change intermediate storage level):
    ```python
    kmeans = KMeans(k=3)          # intermediate data always MEMORY_AND_DISK
    ```
    
    After:
    ```python
    kmeans = KMeans(k=3).setIntermediateStorageLevel("DISK_ONLY")
    ```
    
    ### How was this patch tested?
    
    - Extended `KMeansSuite` to assert the new param's default value, that it 
can be set, and that invalid values (`"NONE"` and non-existent levels) are 
rejected. `KMeansSuite` passes (15/15).
    - PySpark param parity is covered by the existing 
`pyspark.ml.tests.test_param.test_java_params`, which checks that Python params 
match their Scala counterparts.
    - `dev/mima` (mllib `mimaReportBinaryIssues`) reports no binary 
compatibility problems; no `MimaExcludes` entries were needed.
    
    ### Was this patch authored or co-authored using generative AI tooling?
    
    Generated-by: Claude Code (Claude Opus 4.8)
    
    Closes #56949 from maoli67660/SPARK-57860.
    
    Lead-authored-by: Mao Li <[email protected]>
    Co-authored-by: Mao Li <[email protected]>
    Signed-off-by: Ruifeng Zheng <[email protected]>
---
 .../org/apache/spark/ml/clustering/KMeans.scala    | 10 +++++++---
 .../ml/param/shared/SharedParamsCodeGen.scala      | 11 ++++++++++-
 .../spark/ml/param/shared/sharedParams.scala       | 21 ++++++++++++++++++++
 .../apache/spark/ml/clustering/KMeansSuite.scala   |  9 +++++++++
 python/pyspark/ml/clustering.py                    |  9 +++++++++
 python/pyspark/ml/param/_shared_params_code_gen.py |  6 ++++++
 python/pyspark/ml/param/shared.py                  | 23 ++++++++++++++++++++++
 7 files changed, 85 insertions(+), 4 deletions(-)

diff --git a/mllib/src/main/scala/org/apache/spark/ml/clustering/KMeans.scala 
b/mllib/src/main/scala/org/apache/spark/ml/clustering/KMeans.scala
index ad6ca0924064..2de3eb531f56 100644
--- a/mllib/src/main/scala/org/apache/spark/ml/clustering/KMeans.scala
+++ b/mllib/src/main/scala/org/apache/spark/ml/clustering/KMeans.scala
@@ -49,7 +49,7 @@ import org.apache.spark.util.VersionUtils.majorVersion
  */
 private[clustering] trait KMeansParams extends Params with HasMaxIter with 
HasFeaturesCol
   with HasSeed with HasPredictionCol with HasTol with HasDistanceMeasure with 
HasWeightCol
-  with HasSolver with HasMaxBlockSizeInMB {
+  with HasSolver with HasMaxBlockSizeInMB with HasIntermediateStorageLevel {
   import KMeans._
 
   /**
@@ -434,6 +434,10 @@ class KMeans @Since("1.5.0") (
   @Since("3.4.0")
   def setMaxBlockSizeInMB(value: Double): this.type = set(maxBlockSizeInMB, 
value)
 
+  /** @group expertSetParam */
+  @Since("5.0.0")
+  def setIntermediateStorageLevel(value: String): this.type = 
set(intermediateStorageLevel, value)
+
   @Since("2.0.0")
   override def fit(dataset: Dataset[_]): KMeansModel = instrumented { instr =>
     transformSchema(dataset.schema, logging = true)
@@ -441,7 +445,7 @@ class KMeans @Since("1.5.0") (
     instr.logPipelineStage(this)
     instr.logDataset(dataset)
     instr.logParams(this, featuresCol, predictionCol, k, initMode, initSteps, 
distanceMeasure,
-      maxIter, seed, tol, weightCol, solver, maxBlockSizeInMB)
+      maxIter, seed, tol, weightCol, solver, maxBlockSizeInMB, 
intermediateStorageLevel)
 
     val oldModel = if (preferBlockSolver(dataset)) {
       trainWithBlock(dataset, instr)
@@ -547,7 +551,7 @@ class KMeans @Since("1.5.0") (
     }
     val maxMemUsage = (actualBlockSizeInMB * 1024L * 1024L).ceil.toLong
     val blocks = InstanceBlock.blokifyWithMaxMemUsage(instances, maxMemUsage)
-      .persist(StorageLevel.MEMORY_AND_DISK)
+      .persist(StorageLevel.fromString($(intermediateStorageLevel)))
       .setName(s"$uid: training blocks (blockSizeInMB=$actualBlockSizeInMB)")
 
     val distanceFunction = getDistanceFunction
diff --git 
a/mllib/src/main/scala/org/apache/spark/ml/param/shared/SharedParamsCodeGen.scala
 
b/mllib/src/main/scala/org/apache/spark/ml/param/shared/SharedParamsCodeGen.scala
index c61aa14edca2..a689ab37d678 100644
--- 
a/mllib/src/main/scala/org/apache/spark/ml/param/shared/SharedParamsCodeGen.scala
+++ 
b/mllib/src/main/scala/org/apache/spark/ml/param/shared/SharedParamsCodeGen.scala
@@ -113,7 +113,13 @@ private[shared] object SharedParamsCodeGen {
         "into blocks. Data is stacked within partitions. If more than 
remaining data size in a " +
         "partition then it is adjusted to the data size. Default 0.0 
represents choosing " +
         "optimal value, depends on specific algorithm. Must be >= 0.",
-        Some("0.0"), isValid = "ParamValidators.gtEq(0.0)", isExpertParam = 
true)
+        Some("0.0"), isValid = "ParamValidators.gtEq(0.0)", isExpertParam = 
true),
+      ParamDesc[String]("intermediateStorageLevel",
+        "StorageLevel for intermediate datasets. Pass in a string 
representation of " +
+        "`StorageLevel`. Cannot be 'NONE'.",
+        Some("\"MEMORY_AND_DISK\""),
+        isValid = "(s: String) => Try(StorageLevel.fromString(s)).isSuccess && 
s != \"NONE\"",
+        isExpertParam = true)
     )
 
     val code = genSharedParams(params)
@@ -248,7 +254,10 @@ private[shared] object SharedParamsCodeGen {
         |
         |package org.apache.spark.ml.param.shared
         |
+        |import scala.util.Try
+        |
         |import org.apache.spark.ml.param._
+        |import org.apache.spark.storage.StorageLevel
         |
         |// DO NOT MODIFY THIS FILE! It was generated by SharedParamsCodeGen.
         |
diff --git 
a/mllib/src/main/scala/org/apache/spark/ml/param/shared/sharedParams.scala 
b/mllib/src/main/scala/org/apache/spark/ml/param/shared/sharedParams.scala
index 425bf91fd00b..0df0567e4692 100644
--- a/mllib/src/main/scala/org/apache/spark/ml/param/shared/sharedParams.scala
+++ b/mllib/src/main/scala/org/apache/spark/ml/param/shared/sharedParams.scala
@@ -17,7 +17,10 @@
 
 package org.apache.spark.ml.param.shared
 
+import scala.util.Try
+
 import org.apache.spark.ml.param._
+import org.apache.spark.storage.StorageLevel
 
 // DO NOT MODIFY THIS FILE! It was generated by SharedParamsCodeGen.
 
@@ -580,4 +583,22 @@ trait HasMaxBlockSizeInMB extends Params {
   /** @group expertGetParam */
   final def getMaxBlockSizeInMB: Double = $(maxBlockSizeInMB)
 }
+
+/**
+ * Trait for shared param intermediateStorageLevel (default: 
"MEMORY_AND_DISK"). This trait may be changed or
+ * removed between minor versions.
+ */
+trait HasIntermediateStorageLevel extends Params {
+
+  /**
+   * Param for StorageLevel for intermediate datasets. Pass in a string 
representation of `StorageLevel`. Cannot be 'NONE'..
+   * @group expertParam
+   */
+  final val intermediateStorageLevel: Param[String] = new Param[String](this, 
"intermediateStorageLevel", "StorageLevel for intermediate datasets. Pass in a 
string representation of `StorageLevel`. Cannot be 'NONE'.", (s: String) => 
Try(StorageLevel.fromString(s)).isSuccess && s != "NONE")
+
+  setDefault(intermediateStorageLevel, "MEMORY_AND_DISK")
+
+  /** @group expertGetParam */
+  final def getIntermediateStorageLevel: String = $(intermediateStorageLevel)
+}
 // scalastyle:on
diff --git 
a/mllib/src/test/scala/org/apache/spark/ml/clustering/KMeansSuite.scala 
b/mllib/src/test/scala/org/apache/spark/ml/clustering/KMeansSuite.scala
index 9e5be94a9ccb..852cce6645aa 100644
--- a/mllib/src/test/scala/org/apache/spark/ml/clustering/KMeansSuite.scala
+++ b/mllib/src/test/scala/org/apache/spark/ml/clustering/KMeansSuite.scala
@@ -58,6 +58,7 @@ class KMeansSuite extends MLTest with DefaultReadWriteTest 
with PMMLReadWriteTes
     assert(kmeans.getTol === 1e-4)
     assert(kmeans.getSolver === KMeans.AUTO)
     assert(kmeans.getDistanceMeasure === KMeans.EUCLIDEAN)
+    assert(kmeans.getIntermediateStorageLevel === "MEMORY_AND_DISK")
     val model = kmeans.setMaxIter(1).fit(dataset)
 
     val transformed = model.transform(dataset)
@@ -85,6 +86,7 @@ class KMeansSuite extends MLTest with DefaultReadWriteTest 
with PMMLReadWriteTes
       .setSeed(123)
       .setTol(1e-3)
       .setDistanceMeasure(KMeans.COSINE)
+      .setIntermediateStorageLevel("MEMORY_ONLY")
 
     assert(kmeans.getK === 9)
     assert(kmeans.getFeaturesCol === "test_feature")
@@ -95,6 +97,7 @@ class KMeansSuite extends MLTest with DefaultReadWriteTest 
with PMMLReadWriteTes
     assert(kmeans.getSeed === 123)
     assert(kmeans.getTol === 1e-3)
     assert(kmeans.getDistanceMeasure === KMeans.COSINE)
+    assert(kmeans.getIntermediateStorageLevel === "MEMORY_ONLY")
   }
 
   test("parameters validation") {
@@ -110,6 +113,12 @@ class KMeansSuite extends MLTest with DefaultReadWriteTest 
with PMMLReadWriteTes
     intercept[IllegalArgumentException] {
       new KMeans().setDistanceMeasure("no_such_a_measure")
     }
+    intercept[IllegalArgumentException] {
+      new KMeans().setIntermediateStorageLevel("NONE")
+    }
+    intercept[IllegalArgumentException] {
+      new KMeans().setIntermediateStorageLevel("no_such_a_level")
+    }
   }
 
   test("fit, transform and summary") {
diff --git a/python/pyspark/ml/clustering.py b/python/pyspark/ml/clustering.py
index c3964724dd3a..ba3e486cb4ef 100644
--- a/python/pyspark/ml/clustering.py
+++ b/python/pyspark/ml/clustering.py
@@ -36,6 +36,7 @@ from pyspark.ml.param.shared import (
     HasCheckpointInterval,
     HasSolver,
     HasMaxBlockSizeInMB,
+    HasIntermediateStorageLevel,
     Param,
     Params,
     TypeConverters,
@@ -582,6 +583,7 @@ class _KMeansParams(
     HasWeightCol,
     HasSolver,
     HasMaxBlockSizeInMB,
+    HasIntermediateStorageLevel,
 ):
     """
     Params for :py:class:`KMeans` and :py:class:`KMeansModel`.
@@ -921,6 +923,13 @@ class KMeans(JavaEstimator[KMeansModel], _KMeansParams, 
JavaMLWritable, JavaMLRe
         """
         return self._set(maxBlockSizeInMB=value)
 
+    @since("5.0.0")
+    def setIntermediateStorageLevel(self, value: str) -> "KMeans":
+        """
+        Sets the value of :py:attr:`intermediateStorageLevel`.
+        """
+        return self._set(intermediateStorageLevel=value)
+
 
 @inherit_doc
 class _BisectingKMeansParams(
diff --git a/python/pyspark/ml/param/_shared_params_code_gen.py 
b/python/pyspark/ml/param/_shared_params_code_gen.py
index bbcaa208a39d..a885d6f1be5e 100644
--- a/python/pyspark/ml/param/_shared_params_code_gen.py
+++ b/python/pyspark/ml/param/_shared_params_code_gen.py
@@ -333,6 +333,12 @@ if __name__ == "__main__":
             "0.0",
             "TypeConverters.toFloat",
         ),
+        (
+            "intermediateStorageLevel",
+            "StorageLevel for intermediate datasets. Cannot be 'NONE'.",
+            '"MEMORY_AND_DISK"',
+            "TypeConverters.toString",
+        ),
         (
             "numTrainWorkers",
             "number of training workers",
diff --git a/python/pyspark/ml/param/shared.py 
b/python/pyspark/ml/param/shared.py
index e60f2a7432f7..ae8100b155e1 100644
--- a/python/pyspark/ml/param/shared.py
+++ b/python/pyspark/ml/param/shared.py
@@ -789,6 +789,29 @@ class HasMaxBlockSizeInMB(Params):
         return self.getOrDefault(self.maxBlockSizeInMB)
 
 
+class HasIntermediateStorageLevel(Params):
+    """
+    Mixin for param intermediateStorageLevel: StorageLevel for intermediate 
datasets. Cannot be 'NONE'.
+    """
+
+    intermediateStorageLevel: "Param[str]" = Param(
+        Params._dummy(),
+        "intermediateStorageLevel",
+        "StorageLevel for intermediate datasets. Cannot be 'NONE'.",
+        typeConverter=TypeConverters.toString,
+    )
+
+    def __init__(self) -> None:
+        super().__init__()
+        self._setDefault(intermediateStorageLevel="MEMORY_AND_DISK")
+
+    def getIntermediateStorageLevel(self) -> str:
+        """
+        Gets the value of intermediateStorageLevel or its default value.
+        """
+        return self.getOrDefault(self.intermediateStorageLevel)
+
+
 class HasNumTrainWorkers(Params):
     """
     Mixin for param numTrainWorkers: number of training workers


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

Reply via email to