Let's assume that I have a use case where I want to groupBy then apply a custom function to the grouped values. In Python, I could accomplish this through:
df.groupby("id").apply(custom_function)
and
@pandas_udf("id string, prediction double", PandasUDFType.GROUPED_MAP)
def custom_function(id, dataframe):
rf = RandomForestRegressor(n_estimators=25, random_state=42)
rf.fit(train_features, dataframe.quantity_sold)
prediction = rf.predict(test_features)
return pd.DataFrame({'id': id, 'prediction': prediction}, index=[0])
I could accomplish the same thing in Scala through:
input.rdd.groupBy(row => row.get(0)).collect().map(data => {
val df = sparkSession.createDataFrame(sparkContext.parallelize(data._2.toSeq), input.schema)
(data._1.toString, df)
}).foldLeft(sparkSession.createDataFrame(sparkContext.emptyRDD[Row], outputSchema))((acc, next) => {
val assembler = new VectorAssembler()
.setInputCols(modelColumns)
.setOutputCol(features)
.transform(next._2)
val forest = oldForest
.fit(assembler)
.transform(testAssembler)
acc.union(forest)
})
If we compare these two workarounds, the upper one works much faster than the below one. I tried to do this without collect, but I get the error RDD transformations and actions can only be invoked by the driver, not inside of other transformations.
I am aware that collect returns the results to the driver as a list, that is why I am forced to use Scala collection API (map and flatMap) to further continue my processing.
My questions regarding this are, is the job not supposed to be spread to executors again once collected to the driver (since I am continuing to use Spark ML API)? Or is everything simply calculated (once collected) in the driver as the code goes back to where main method is executed? Basically, why is the run very slow and is there any approach to make this process better without using Python?
Thank you!
EDIT: Managed to solve this (an example as below); say we have this dataset:
+---+-----+------+-----+
|id |first|second|third|
+---+-----+------+-----+
|1 |1.0 |1.0 |1.0 |
|1 |1.0 |2.0 |2.0 |
|1 |1.0 |3.0 |3.0 |
|1 |1.0 |4.0 |4.0 |
|1 |1.0 |5.0 |5.0 |
+---+-----+------+-----+
Our goal is to group by id, then for the grouped columns (first, second and third), we want to train the model then predict something (with column third being our label).
To group and apply the UDAF (as suggested from werner):
val myAggFct = udaf(MyAgg).apply(array("first", "second", "third"))
df.groupBy("id").agg(myAggFct)
myAggFct UDF is implemented as below:
object MyAgg extends Aggregator[Seq[Double], Seq[Seq[Double]], String] {
override def zero: Seq[Seq[Double]] = scala.collection.mutable.Seq[Seq[Double]]()
override def reduce(b: Seq[Seq[Double]], a: Seq[Double]): Seq[Seq[Double]] = b :+ a
override def merge(b1: Seq[Seq[Double]], b2: Seq[Seq[Double]]): Seq[Seq[Double]] = b1 ++ b2
override def finish(allInts: Seq[Seq[Double]]): String = {
// Defining the attributes (first, second and third, as our dataset)
val array: List[Attribute] = List() :+
new Attribute("first") :+
new Attribute("second") :+
new Attribute("third")
// Creation of the instance and defining what we want to predict, in our case, the last attribute
// aka. `third`
val dataRaw = new Instances("train", new util.ArrayList[Attribute](array.asJava), 0)
dataRaw.setClassIndex(dataRaw.numAttributes() - 1)
// Converting our Seq[Seq[Double]] to Dense Instances, so we can add it to `dataRaw`
// aka. our trained model
dataRaw.addAll(allInts.map(v => new DenseInstance(1.0, v.toArray)).asJava)
// We create a Random Forest object and we use `dataRaw` as classifier
val mlp = new RandomForest()
mlp.buildClassifier(dataRaw)
// Give it a test case, in this case, we want to see where first = 1.0 and second = 2.0 fall into
val testInstance = new DenseInstance(1.0, Seq(1.0, 2.0).toArray)
testInstance.setDataset(dataRaw)
// We classify the instance and add some content for clearer output
mlp.classifyInstance(testInstance).toString + ": " + allInts.mkString(", ")
}
override def bufferEncoder: Encoder[Seq[Seq[Double]]] = newSequenceEncoder[Seq[Seq[Double]]]
override def outputEncoder: Encoder[String] = Encoders.STRING
}
Final result:
+---+------------------------------------------------------------------------------------------------------------+
|id |myagg$(array(first, second, third)) |
+---+------------------------------------------------------------------------------------------------------------+
|1 |2.2: List(1.0, 1.0, 1.0), List(1.0, 2.0, 2.0), List(1.0, 3.0, 3.0), List(1.0, 4.0, 4.0), List(1.0, 5.0, 5.0)|
+---+------------------------------------------------------------------------------------------------------------+
In this case, there might be some overhead while converting from/to Java and Scala.