-
Notifications
You must be signed in to change notification settings - Fork 29.3k
[SPARK-11612] [ML] Pipeline and PipelineModel persistence #9674
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 5 commits
3b92783
a367e74
38d262c
5d13393
caf57c2
1d1d31c
f791010
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -22,12 +22,19 @@ import java.{util => ju} | |
| import scala.collection.JavaConverters._ | ||
| import scala.collection.mutable.ListBuffer | ||
|
|
||
| import org.apache.spark.Logging | ||
| import org.apache.hadoop.fs.Path | ||
| import org.json4s._ | ||
| import org.json4s.jackson.JsonMethods._ | ||
|
|
||
| import org.apache.spark.{SparkContext, Logging} | ||
| import org.apache.spark.annotation.{DeveloperApi, Experimental} | ||
| import org.apache.spark.ml.param.{Param, ParamMap, Params} | ||
| import org.apache.spark.ml.util.Identifiable | ||
| import org.apache.spark.ml.util.Reader | ||
| import org.apache.spark.ml.util.Writer | ||
| import org.apache.spark.ml.util._ | ||
| import org.apache.spark.sql.DataFrame | ||
| import org.apache.spark.sql.types.StructType | ||
| import org.apache.spark.util.Utils | ||
|
|
||
| /** | ||
| * :: DeveloperApi :: | ||
|
|
@@ -82,7 +89,7 @@ abstract class PipelineStage extends Params with Logging { | |
| * an identity transformer. | ||
| */ | ||
| @Experimental | ||
| class Pipeline(override val uid: String) extends Estimator[PipelineModel] { | ||
| class Pipeline(override val uid: String) extends Estimator[PipelineModel] with Writable { | ||
|
|
||
| def this() = this(Identifiable.randomUID("pipeline")) | ||
|
|
||
|
|
@@ -166,6 +173,34 @@ class Pipeline(override val uid: String) extends Estimator[PipelineModel] { | |
| "Cannot have duplicate components in a pipeline.") | ||
| theStages.foldLeft(schema)((cur, stage) => stage.transformSchema(cur)) | ||
| } | ||
|
|
||
| override def write: Writer = new PipelineWriter(this) | ||
| } | ||
|
|
||
| object Pipeline extends Readable[Pipeline] { | ||
|
|
||
| override def read: Reader[Pipeline] = new PipelineReader | ||
|
|
||
| override def load(path: String): Pipeline = read.load(path) | ||
| } | ||
|
|
||
| private[ml] class PipelineWriter(instance: Pipeline) extends Writer { | ||
|
|
||
| PipelineSharedWriter.validateStages(instance.getStages) | ||
|
|
||
| override protected def saveImpl(path: String): Unit = | ||
| PipelineSharedWriter.saveImpl(instance, instance.getStages, sc, path) | ||
| } | ||
|
|
||
| private[ml] class PipelineReader extends Reader[Pipeline] { | ||
|
|
||
| /** Checked against metadata when loading model */ | ||
| private val className = "org.apache.spark.ml.Pipeline" | ||
|
|
||
| override def load(path: String): Pipeline = { | ||
| val (uid: String, stages: Array[PipelineStage]) = PipelineSharedReader.load(className, sc, path) | ||
| new Pipeline(uid).setStages(stages) | ||
| } | ||
| } | ||
|
|
||
| /** | ||
|
|
@@ -176,7 +211,7 @@ class Pipeline(override val uid: String) extends Estimator[PipelineModel] { | |
| class PipelineModel private[ml] ( | ||
| override val uid: String, | ||
| val stages: Array[Transformer]) | ||
| extends Model[PipelineModel] with Logging { | ||
| extends Model[PipelineModel] with Writable with Logging { | ||
|
|
||
| /** A Java/Python-friendly auxiliary constructor. */ | ||
| private[ml] def this(uid: String, stages: ju.List[Transformer]) = { | ||
|
|
@@ -200,4 +235,121 @@ class PipelineModel private[ml] ( | |
| override def copy(extra: ParamMap): PipelineModel = { | ||
| new PipelineModel(uid, stages.map(_.copy(extra))).setParent(parent) | ||
| } | ||
|
|
||
| override def write: Writer = new PipelineModelWriter(this) | ||
| } | ||
|
|
||
| object PipelineModel extends Readable[PipelineModel] { | ||
|
|
||
| override def read: Reader[PipelineModel] = new PipelineModelReader | ||
|
|
||
| override def load(path: String): PipelineModel = read.load(path) | ||
| } | ||
|
|
||
| private[ml] class PipelineModelWriter(instance: PipelineModel) extends Writer { | ||
|
|
||
| PipelineSharedWriter.validateStages(instance.stages.asInstanceOf[Array[PipelineStage]]) | ||
|
|
||
| override protected def saveImpl(path: String): Unit = PipelineSharedWriter.saveImpl(instance, | ||
| instance.stages.asInstanceOf[Array[PipelineStage]], sc, path) | ||
| } | ||
|
|
||
| private[ml] class PipelineModelReader extends Reader[PipelineModel] { | ||
|
|
||
| /** Checked against metadata when loading model */ | ||
| private val className = "org.apache.spark.ml.PipelineModel" | ||
|
|
||
| override def load(path: String): PipelineModel = { | ||
| val (uid: String, stages: Array[PipelineStage]) = | ||
| PipelineSharedReader.load(className, sc, path) | ||
| val transformers = stages map { | ||
| case stage: Transformer => stage | ||
| case stage => throw new RuntimeException(s"PipelineModel.read loaded a stage but found it" + | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. minor: |
||
| s" was not a Transformer. Bad stage: ${stage.uid}") | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. include |
||
| } | ||
| new PipelineModel(uid, transformers) | ||
| } | ||
| } | ||
|
|
||
| /** Methods for [[Writer]] shared between [[Pipeline]] and [[PipelineModel]] */ | ||
| private[ml] object PipelineSharedWriter { | ||
|
|
||
| import org.json4s.JsonDSL._ | ||
|
|
||
| /** Check that all stages are Writable */ | ||
| def validateStages(stages: Array[PipelineStage]): Unit = { | ||
| stages.foreach { | ||
| case stage: Writable => // good | ||
| case stage => | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. minor: |
||
| throw new UnsupportedOperationException("Pipeline write will fail on this Pipeline" + | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. minor:
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. But a user could write: |
||
| s" because it contains a stage which does not implement Writable. Non-Writable stage:" + | ||
| s" ${stage.uid}") | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Include |
||
| } | ||
| } | ||
|
|
||
| def saveImpl( | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. minor:
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I like saveImpl since it's analogous to the other saveImpl methods which call it |
||
| instance: Params, | ||
| stages: Array[PipelineStage], | ||
| sc: SparkContext, | ||
| path: String): Unit = { | ||
| // Copied and edited from DefaultParamsWriter.saveMetadata | ||
| // TODO: modify DefaultParamsWriter.saveMetadata to avoid duplication | ||
| val uid = instance.uid | ||
| val cls = instance.getClass.getName | ||
| val stageUids = stages.map(_.uid) | ||
| val jsonParams = List("stageUids" -> parse(compact(render(stageUids.toSeq)))) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is another way than this better? |
||
| val metadata = ("class" -> cls) ~ | ||
| ("timestamp" -> System.currentTimeMillis()) ~ | ||
| ("sparkVersion" -> sc.version) ~ | ||
| ("uid" -> uid) ~ | ||
| ("paramMap" -> jsonParams) | ||
| val metadataPath = new Path(path, "metadata").toString | ||
| val metadataJson = compact(render(metadata)) | ||
| sc.parallelize(Seq(metadataJson), 1).saveAsTextFile(metadataPath) | ||
|
|
||
| // Save stages | ||
| val stagesDir = new Path(path, "stages").toString | ||
| stages.foreach { | ||
| case stage: Writable => | ||
| val stagePath = new Path(stagesDir, stage.uid).toString | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It is useful if we prefix the stagePath with index, e.g., |
||
| stage.write.save(stagePath) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| /** Methods for [[Reader]] shared between [[Pipeline]] and [[PipelineModel]] */ | ||
| private[ml] object PipelineSharedReader { | ||
|
|
||
| def load(className: String, sc: SparkContext, path: String): (String, Array[PipelineStage]) = { | ||
| val metadata = DefaultParamsReader.loadMetadata(path, sc, className) | ||
|
|
||
| implicit val format = DefaultFormats | ||
| val stagesDir = new Path(path, "stages").toString | ||
| val stageUids: Array[String] = metadata.params match { | ||
| case JObject(pairs) => | ||
| if (pairs.length != 1) { | ||
| // Should not happen unless file is corrupted or we have a bug. | ||
| throw new RuntimeException( | ||
| s"Pipeline read expected 1 Param (stageUids), but found ${pairs.length}.") | ||
| } | ||
| pairs.head match { | ||
| case ("stageUids", jsonValue) => | ||
| parse(compact(render(jsonValue))).extract[Seq[String]].toArray | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Would |
||
| case (paramName, jsonValue) => | ||
| // Should not happen unless file is corrupted or we have a bug. | ||
| throw new RuntimeException(s"Pipeline read encountered unexpected Param $paramName" + | ||
| s" in metadata: ${metadata.metadataStr}") | ||
| } | ||
| case _ => | ||
| throw new IllegalArgumentException( | ||
| s"Cannot recognize JSON metadata: ${metadata.metadataStr}.") | ||
| } | ||
| val stages: Array[PipelineStage] = stageUids.map { stageUid => | ||
| val stagePath = new Path(stagesDir, stageUid).toString | ||
| val stageMetadata = DefaultParamsReader.loadMetadata(stagePath, sc) | ||
| val cls = Utils.classForName(stageMetadata.className) | ||
| cls.getMethod("read").invoke(cls).asInstanceOf[Reader[PipelineStage]].load(stagePath) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| } | ||
| (metadata.uid, stages) | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should users be able to save an incomplete pipeline? For example, I could make a template pipeline, send it to other users, and they only need to fill in some required params like inputCol after they load it back.