Skip to content
Closed
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 40 additions & 5 deletions mllib/src/main/scala/org/apache/spark/ml/ann/Layer.scala
Original file line number Diff line number Diff line change
Expand Up @@ -361,9 +361,15 @@ private[ann] trait TopologyModel extends Serializable {
* Forward propagation
*
* @param data input data
* @param includeLastLayer include last layer when computing. In MultilayerPerceptronClassifier,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This text is unclear. This phrasing is better: "Include the last layer in the output. In MultilayerPerceptronClassifier, the last layer is always softmax; the last layer of outputs is needed for class predictions, but not for rawPrediction."

* The last layer is always softmax, add the includeLastLayer parameter,
* when true the forward computing will contains last layer, otherwise
* not. The parameter is used when we need rawPrediction, the last layer
* softmax should discard.
*
* @return array of outputs for each of the layers
*/
def forward(data: BDM[Double]): Array[BDM[Double]]
def forward(data: BDM[Double], includeLastLayer: Boolean): Array[BDM[Double]]

/**
* Prediction of the model
Expand All @@ -373,6 +379,22 @@ private[ann] trait TopologyModel extends Serializable {
*/
def predict(data: Vector): Vector

/**
* Raw prediction of the model

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This documentation does not add any information. Can you please link to ProbabilisticClassifier instead?

*
* @param data input data
* @return raw prediction
*/
def predictRaw(data: Vector): Vector

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please match the Classifier API here (data -> features) unless there's a reason to deviate

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ping: rename data -> features


/**
* Probability of the model

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This documentation does not add any information. Can you please link to ProbabilisticClassifier instead?

*
* @param data input data

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"input data" sounds like input feature values, which is not correct. Why not match the ProbabilisticClassifier API?

* @return probability
*/
def raw2ProbabilityInPlace(data: Vector): Vector

/**
* Computes gradient for the network
*
Expand Down Expand Up @@ -463,7 +485,7 @@ private[ml] class FeedForwardModel private(
private var outputs: Array[BDM[Double]] = null
private var deltas: Array[BDM[Double]] = null

override def forward(data: BDM[Double]): Array[BDM[Double]] = {
override def forward(data: BDM[Double], includeLastLayer: Boolean): Array[BDM[Double]] = {
// Initialize output arrays for all layers. Special treatment for InPlace

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The last layer is always softmax, add the containsLastLayer parameter, when true the forward computing will contains last layer, otherwise not. The parameter is used when we need rawPrediction, the last layer softmax should discard.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you add the above comment in the code, it could be useful for folks reading/editing this in the future.

Also it seems like the last layer could also be a SigmoidLayerWithSqueredError or a SigmiodFunction do we need to hand those cases any differently?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@MrBago In MultiLayerPerceptronClassifier.train there is a line:

val topology = FeedForwardTopology.multiLayerPerceptron(myLayers, softmaxOnTop = true)

So MultiLayerPerceptronClassifier always use softmax as the last layer.

val currentBatchSize = data.cols
// TODO: allocate outputs as one big array and then create BDMs from it
Expand All @@ -481,7 +503,8 @@ private[ml] class FeedForwardModel private(
}
}
layerModels(0).eval(data, outputs(0))
for (i <- 1 until layerModels.length) {
val end = if (includeLastLayer) layerModels.length else layerModels.length - 1
for (i <- 1 until end) {
layerModels(i).eval(outputs(i - 1), outputs(i))
}
outputs
Expand All @@ -492,7 +515,7 @@ private[ml] class FeedForwardModel private(
target: BDM[Double],
cumGradient: Vector,
realBatchSize: Int): Double = {
val outputs = forward(data)
val outputs = forward(data, true)
val currentBatchSize = data.cols
// TODO: allocate deltas as one big array and then create BDMs from it
if (deltas == null || deltas(0).cols != currentBatchSize) {
Expand Down Expand Up @@ -527,9 +550,21 @@ private[ml] class FeedForwardModel private(

override def predict(data: Vector): Vector = {
val size = data.size
val result = forward(new BDM[Double](size, 1, data.toArray))
val result = forward(new BDM[Double](size, 1, data.toArray), true)
Vectors.dense(result.last.toArray)
}

override def predictRaw(data: Vector): Vector = {
val size = data.size

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

add predictRaw method, computing without last layer (softmax)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This temp val of "size" is only used once, so I recommend removing it to make the code clearer.

val result = forward(new BDM[Double](size, 1, data.toArray), false)
Vectors.dense(result(result.length - 2).toArray)
}

override def raw2ProbabilityInPlace(data: Vector): Vector = {
val dataMatrix = new BDM[Double](data.size, 1, data.toArray)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

add raw2ProbabilityInPlace, what it compute is:

softmax(rawPredictionsVector) ==> predictionsVector

directly call the last layer function to compute it.

layerModels.last.eval(dataMatrix, dataMatrix)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This assumes that the eval method can operate in-place. That is fine for the last layer for MLP (SoftmaxLayerModelWithCrossEntropyLoss), but not OK in general. More generally, these methods for classifiers should not go in the very general TopologyModel abstraction; that abstraction may be used in the future for regression as well. I'd be fine with putting this classification-specific logic in MLP itself; we do not need to generalize the logic until we add other Classifiers, which might take a long time.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ping: If this proposal sounds good, then can you please update accordingly?

data
}
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ import org.apache.spark.ml.util._
import org.apache.spark.sql.Dataset

/** Params for Multilayer Perceptron. */
private[classification] trait MultilayerPerceptronParams extends PredictorParams
private[classification] trait MultilayerPerceptronParams extends ProbabilisticClassifierParams
with HasSeed with HasMaxIter with HasTol with HasStepSize with HasSolver {

import MultilayerPerceptronClassifier._
Expand Down Expand Up @@ -143,7 +143,8 @@ private object LabelConverter {
@Since("1.5.0")
class MultilayerPerceptronClassifier @Since("1.5.0") (
@Since("1.5.0") override val uid: String)
extends Predictor[Vector, MultilayerPerceptronClassifier, MultilayerPerceptronClassificationModel]
extends ProbabilisticClassifier[Vector, MultilayerPerceptronClassifier,
MultilayerPerceptronClassificationModel]
with MultilayerPerceptronParams with DefaultParamsWritable {

@Since("1.5.0")
Expand Down Expand Up @@ -301,13 +302,13 @@ class MultilayerPerceptronClassificationModel private[ml] (
@Since("1.5.0") override val uid: String,
@Since("1.5.0") val layers: Array[Int],
@Since("2.0.0") val weights: Vector)
extends PredictionModel[Vector, MultilayerPerceptronClassificationModel]
extends ProbabilisticClassificationModel[Vector, MultilayerPerceptronClassificationModel]
with Serializable with MLWritable {

@Since("1.6.0")
override val numFeatures: Int = layers.head

private val mlpModel = FeedForwardTopology
private[ml] val mlpModel = FeedForwardTopology
.multiLayerPerceptron(layers, softmaxOnTop = true)
.model(weights)

Expand Down Expand Up @@ -335,6 +336,14 @@ class MultilayerPerceptronClassificationModel private[ml] (
@Since("2.0.0")
override def write: MLWriter =
new MultilayerPerceptronClassificationModel.MultilayerPerceptronClassificationModelWriter(this)

override protected def raw2probabilityInPlace(rawPrediction: Vector): Vector = {
mlpModel.raw2ProbabilityInPlace(rawPrediction)
}

override protected def predictRaw(features: Vector): Vector = mlpModel.predictRaw(features)

override def numClasses: Int = layers.last
}

@Since("2.0.0")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ class GradientSuite extends SparkFunSuite with MLlibTestSparkContext {
}

private def computeLoss(input: BDM[Double], target: BDM[Double], model: TopologyModel): Double = {
val outputs = model.forward(input)
val outputs = model.forward(input, true)
model.layerModels.last match {
case layerWithLoss: LossFunction =>
layerWithLoss.loss(outputs.last, target, new BDM[Double](target.rows, target.cols))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import org.apache.spark.mllib.linalg.{Vectors => OldVectors}
import org.apache.spark.mllib.regression.{LabeledPoint => OldLabeledPoint}
import org.apache.spark.mllib.util.MLlibTestSparkContext
import org.apache.spark.sql.{Dataset, Row}
import org.apache.spark.sql.functions._

class MultilayerPerceptronClassifierSuite
extends SparkFunSuite with MLlibTestSparkContext with DefaultReadWriteTest {
Expand Down Expand Up @@ -82,6 +83,49 @@ class MultilayerPerceptronClassifierSuite
}
}

test("strong dataset test") {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Make the test title more descriptive so it is clear what it is testing. E.g. "Predicted class probabilities: calibration on toy dataset"

val layers = Array[Int](4, 5, 5, 2)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you make this test faster by using a simpler network, e.g., by removing one of the middle layers?


val strongDataset = Seq(
(Vectors.dense(1, 2, 3, 4), 0d, Vectors.dense(1d, 0d)),
(Vectors.dense(4, 3, 2, 1), 1d, Vectors.dense(0d, 1d)),
(Vectors.dense(1, 1, 1, 1), 0d, Vectors.dense(.5, .5)),
(Vectors.dense(1, 1, 1, 1), 1d, Vectors.dense(.5, .5))
).toDF("features", "label", "expectedProbability")
val trainer = new MultilayerPerceptronClassifier()
.setLayers(layers)
.setBlockSize(1)
.setSeed(123L)
.setMaxIter(100)
.setSolver("l-bfgs")
val model = trainer.fit(strongDataset)
val result = model.transform(strongDataset)
model.setProbabilityCol("probability")
MLTestingUtils.checkCopyAndUids(trainer, model)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

checkCopyAndUids is a generic test which should only be run in a single test; it does not need to be run in each test. Please remove it from here.

// result.select("probability").show(false)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The result is

+------------------------------------------+
|probability                               |
+------------------------------------------+
|[0.9999995713441748,4.2865582522823835E-7]|
|[1.992910055147819E-9,0.9999999980070899] |
|[0.4999458983233704,0.5000541016766296]   |
|[0.4999458983233704,0.5000541016766296]   |
+------------------------------------------+

cc @MrBago Thanks!

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

remove old comment

result.select("probability", "expectedProbability").collect().foreach {
case Row(p: Vector, e: Vector) =>
assert(p ~== e absTol 1e-3)
}
}

@WeichenXu123 WeichenXu123 Aug 8, 2017

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@MrBago
How do you like this test ?
The probability it generate is

+--------------------------------------------------------------------------------------+
|probability                                                                           |
+--------------------------------------------------------------------------------------+
|[0.9917274999513315,0.001511626318489583,0.004831796668307991,0.0019290770618710876]  |
|[4.2392735713619E-12,0.9999999999955336,1.8369996605279208E-14,2.0871629225077174E-13]|
|[1.8975708749716946E-4,5.191732707447977E-22,0.5010860788259045,0.49872416408659836]  |
|[1.6776134471360903E-4,3.9309610969078615E-22,0.49629577580941386,0.5035364628458726] |
+--------------------------------------------------------------------------------------+

it contains some values near 0.5


test("test model probability") {
val layers = Array[Int](2, 5, 2)
val trainer = new MultilayerPerceptronClassifier()
.setLayers(layers)
.setBlockSize(1)
.setSeed(123L)
.setMaxIter(100)
.setSolver("l-bfgs")
val model = trainer.fit(dataset)
model.setProbabilityCol("probability")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's the default already, right?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ping --- this should not be necessary

val result = model.transform(dataset)
val features2prob = udf { features: Vector => model.mlpModel.predict(features) }
val cmpVec = udf { (v1: Vector, v2: Vector) => v1 ~== v2 relTol 1e-3 }
assert(result.select(cmpVec(features2prob(col("features")), col("probability")))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If this test fails, it will not give much info. How about collecting the data and comparing on the driver?

.rdd.map(_.getBoolean(0)).reduce(_ && _))
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should include a stronger test for this. I did a quick search and couldn't find a strong test for mlpModel.predict, it might be good to add one. Also, I believe this xor dataset only produces probability predictions ~equal to 0 or 1.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@MrBago
Which way of the strong test should be done ? Add a test to check the probability vector equals given vectors ?

test("Test setWeights by training restart") {
val dataFrame = Seq(
(Vectors.dense(0.0, 0.0), 0.0),
Expand Down
4 changes: 2 additions & 2 deletions python/pyspark/ml/classification.py
Original file line number Diff line number Diff line change
Expand Up @@ -1378,7 +1378,7 @@ class MultilayerPerceptronClassifier(JavaEstimator, HasFeaturesCol, HasLabelCol,
>>> testDF = spark.createDataFrame([
... (Vectors.dense([1.0, 0.0]),),
... (Vectors.dense([0.0, 0.0]),)], ["features"])
>>> model.transform(testDF).show()
>>> model.transform(testDF).select("features", "prediction").show()
+---------+----------+
| features|prediction|
+---------+----------+
Expand Down Expand Up @@ -1512,7 +1512,7 @@ def getInitialWeights(self):
return self.getOrDefault(self.initialWeights)


class MultilayerPerceptronClassificationModel(JavaModel, JavaPredictionModel, JavaMLWritable,
class MultilayerPerceptronClassificationModel(JavaModel, JavaClassificationModel, JavaMLWritable,
JavaMLReadable):
"""
Model fitted by MultilayerPerceptronClassifier.
Expand Down