HUANG GUAN LUN created SPARK-60151:
--------------------------------------

             Summary: CrossValidatorModel.load() returns the last fold's 
sub-models for every fold
                 Key: SPARK-60151
                 URL: https://issues.apache.org/jira/browse/SPARK-60151
             Project: Spark
          Issue Type: Bug
          Components: ML, PySpark
    Affects Versions: 4.0.4, 4.1.3, 4.2.0, 3.5.8, 5.0.0
            Reporter: HUANG GUAN LUN


When a CrossValidatorModel is saved with sub-models and loaded back in PySpark, 
every fold's entry of \{{subModels}} refers to the same list, which holds the 
last fold's models.

The cause is in \{{CrossValidatorModelReader.load}} 
(python/pyspark/ml/tuning.py):
{code:python}
subModels = [[None] * len(estimatorParamMaps)] * numFolds
{code}
{\{[x] * n}} repeats a reference to the same inner list, so each fold's 
assignment overwrites the previous one. \{{CrossValidator._fit}}, 
\{{CrossValidatorModel.copy}} and \{{_from_java}} all build an independent list 
per fold, as does the Scala reader (\{{Array.fill(numFolds)(...)}}).

The files on disk are correct. Only the PySpark reader is affected. 
\{{bestModel}} and \{{avgMetrics}} are not affected. However, saving a loaded 
model again writes the last fold's models into every fold directory, so the 
original sub-models are lost.

*Reproduce*
{code:python}
from pyspark.ml.classification import LogisticRegression
from pyspark.ml.evaluation import BinaryClassificationEvaluator
from pyspark.ml.linalg import Vectors
from pyspark.ml.tuning import CrossValidator, CrossValidatorModel, 
ParamGridBuilder

df = spark.createDataFrame(
    [(Vectors.dense([float(i)]), float(i % 2)) for i in range(30)], 
["features", "label"])
lr = LogisticRegression(maxIter=2)
grid = ParamGridBuilder().addGrid(lr.regParam, [0.0, 0.1]).build()
cv = CrossValidator(estimator=lr, estimatorParamMaps=grid,
                    evaluator=BinaryClassificationEvaluator(),
                    numFolds=3, collectSubModels=True, seed=1)
model = cv.fit(df)
model.write().save("/tmp/cv")
loaded = CrossValidatorModel.load("/tmp/cv")

print([m.coefficients for m in model.subModels[0]])   # fold 0
print([m.coefficients for m in loaded.subModels[0]])  # same as fold 2
print(loaded.subModels[0] is loaded.subModels[1])     # True
{code}

First coefficient of each sub-model:
|| ||fold0||fold1||fold2||
|after fit|[0.0705, 0.0439]|[0.0067, 0.0047]|[-0.0155, -0.0108]|
|on disk after save|[0.0705, 0.0439]|[0.0067, 0.0047]|[-0.0155, -0.0108]|
|after load|[-0.0155, -0.0108]|[-0.0155, -0.0108]|[-0.0155, -0.0108]|

The existing test in python/pyspark/ml/tests/test_tuning.py does not catch 
this. It compares \{{model.subModels[i][j].uid}} with itself, and after loading 
it only checks the list lengths. All sub-models share the estimator's uid, so a 
uid comparison would not detect it either.

The line was introduced by SPARK-33520.



--
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