Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
d46e5ed
add lbfgs as default optimizer of LinearSVC
YY-OnCall May 4, 2017
f7d5559
set owlqn as default
YY-OnCall May 6, 2017
8a7c10f
set check
YY-OnCall May 9, 2017
4ce0787
:Merge remote-tracking branch 'upstream/master' into svclbfgs
YY-OnCall Jun 12, 2017
c8afc63
Merge remote-tracking branch 'upstream/master' into svclbfgs
YY-OnCall Jun 13, 2017
3707580
merge loss change
YY-OnCall Jun 13, 2017
2ffd0eb
Merge remote-tracking branch 'upstream/master' into svclbfgs
YY-OnCall Jun 13, 2017
2ca5a74
fix r and python
YY-OnCall Jun 14, 2017
5f7f456
switch between Hinge and Square
YY-OnCall Jun 14, 2017
d19f619
Merge remote-tracking branch 'upstream/master' into svclbfgs
YY-OnCall Jun 15, 2017
0297057
use RDDLossFunction
YY-OnCall Jun 15, 2017
15d611e
merge conflict
YY-OnCall Jun 26, 2017
a545267
Merge remote-tracking branch 'upstream/master' into svclbfgs
YY-OnCall Jun 27, 2017
7be6bac
r and new ut
YY-OnCall Jun 27, 2017
aaf35ec
ut update
YY-OnCall Jun 30, 2017
ea82f35
merge conflict
YY-OnCall Sep 2, 2017
93f7b68
merge conflict and add unit tests
YY-OnCall Sep 3, 2017
cec628b
style
YY-OnCall Sep 3, 2017
55ce6b9
resolve conflict
YY-OnCall Sep 15, 2017
0f5cad5
fix python ut
YY-OnCall Sep 16, 2017
bf4d955
resolve conflict
YY-OnCall Oct 2, 2017
1f8e984
style
YY-OnCall Oct 2, 2017
a6b4cda
Merge remote-tracking branch 'upstream/master' into svclbfgs
YY-OnCall Oct 3, 2017
f778f97
Merge remote-tracking branch 'upstream/master' into svclbfgs
YY-OnCall Oct 14, 2017
0bb5afe
minor updates
YY-OnCall Oct 15, 2017
64bc339
Merge remote-tracking branch 'upstream/master' into svclbfgs
YY-OnCall Oct 23, 2017
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
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,13 @@

package org.apache.spark.ml.classification

import java.util.Locale

import scala.collection.mutable

import breeze.linalg.{DenseVector => BDV}
import breeze.optimize.{CachedDiffFunction, DiffFunction, OWLQN => BreezeOWLQN}
import breeze.optimize.{CachedDiffFunction, DiffFunction, LBFGS => BreezeLBFGS,
OWLQN => BreezeOWLQN}
import org.apache.hadoop.fs.Path

import org.apache.spark.SparkException
Expand All @@ -42,7 +45,21 @@ import org.apache.spark.sql.functions.{col, lit}
/** Params for linear SVM Classifier. */
private[classification] trait LinearSVCParams extends ClassifierParams with HasRegParam
with HasMaxIter with HasFitIntercept with HasTol with HasStandardization with HasWeightCol
with HasThreshold with HasAggregationDepth
with HasThreshold with HasAggregationDepth {

/**
* The optimization algorithm for LinearSVC.
* Supported options: "lbfgs" and "owlqn".
* (default: "lbfgs")
* @group param
*/
final val optimizer: Param[String] = new Param[String](this, "optimizer", "The optimization" +
" algorithm to be used", ParamValidators.inArray[String](LinearSVC.supportedOptimizers))

/** @group getParam */
final def getOptimizer: String = $(optimizer)

}

/**
* :: Experimental ::
Expand All @@ -60,6 +77,8 @@ class LinearSVC @Since("2.2.0") (
extends Classifier[Vector, LinearSVC, LinearSVCModel]
with LinearSVCParams with DefaultParamsWritable {

import LinearSVC._

@Since("2.2.0")
def this() = this(Identifiable.randomUID("linearsvc"))

Expand Down Expand Up @@ -145,6 +164,15 @@ class LinearSVC @Since("2.2.0") (
def setAggregationDepth(value: Int): this.type = set(aggregationDepth, value)
setDefault(aggregationDepth -> 2)

/**
* Set optimizer for LinearSVC. Supported options: "lbfgs" and "owlqn".
*
* @group setParam
*/
@Since("2.2.0")
def setOptimizer(value: String): this.type = set(optimizer, value.toLowerCase(Locale.ROOT))

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.

Maybe just call it solver, since LinearRegression also has something similar like this.

@MLnick MLnick May 5, 2017

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.

👍 - can use HasSolver trait

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.

The solver is l-bfgs if training with lbfgs(l2 reg) or owlqn(l1/elasticNet reg) for other algorithms such as LinearRegression and LogisticRegression, so I concerned that it may confuse users if we distinguish them in LinearSVC. Or we should clarify it in document. Thanks.

setDefault(optimizer -> "lbfgs")

@Since("2.2.0")
override def copy(extra: ParamMap): LinearSVC = defaultCopy(extra)

Expand Down Expand Up @@ -205,15 +233,21 @@ class LinearSVC @Since("2.2.0") (
val costFun = new LinearSVCCostFun(instances, $(fitIntercept),
$(standardization), bcFeaturesStd, regParamL2, $(aggregationDepth))

def regParamL1Fun = (index: Int) => 0D
val optimizer = new BreezeOWLQN[Int, BDV[Double]]($(maxIter), 10, regParamL1Fun, $(tol))
val optimizerAlgo = $(optimizer) match {

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 refresh me on why OWLQN was used originally? AFAIK OWLQN is for L1 regularized loss functions, while L-BFGS only supports L2?

@yanboliang yanboliang May 5, 2017

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.

@MLnick Hinge Loss is a non-smooth and non-differentiable loss, the convex optimization problem is a non-smooth one, so we use use OWL-QN rather than L-BFGS as the underlying optimizer.
@hhbyyh AFAIK in cases where L-BFGS does not encounter any non-smooth point, it often converges to the optimum faster, but there is change of failure on non-smooth point.

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.

Thanks for providing the info @yanboliang. I'm thinking we should provide both LBFGS and OWNQN as options for L1-SVM (Standard hinge). From the several dataset I tested, LBFGS converges as good as (or even better than) OWNQN and uses part of the time. Yet due to the potential failure, we may not use LBFGS as the default optimizer(solver).

A better solution as I see is to use L2-SVM(squared hinge loss) with LBFGS (combine this with #17645), but the change may be too large to be included into 2.2 now.

@yanboliang yanboliang May 8, 2017

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 saw other libraries like sklearn.svm.linearSVC use squared hinge loss as the default loss function, it's differentiable. What about merging #17645 firstly and then we can use LBFGS as the default solver as well? Otherwise, we would make behavior change if we make this switch after 2.2 release. cc @jkbradley

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.

Sounds good to me. But we'll also need to update python and R API. I don't know the release date of 2.2.

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.

Let's wait for more other comments, if we reach a consensus, I'd like to help review and merge this to catch 2.2. cc @jkbradley @MLnick

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.

Personally I would be in favor of making square hinge the default with L-BFGS. But we would need to make it clear to users that if they select the pure hinge loss then there is a risk involved in using L-BFGS rather than OWL-QN.

case LBFGS => new BreezeLBFGS[BDV[Double]]($(maxIter), 10, $(tol))
case OWLQN =>
def regParamL1Fun = (index: Int) => 0D
new BreezeOWLQN[Int, BDV[Double]]($(maxIter), 10, regParamL1Fun, $(tol))
case _ => throw new SparkException ("unexpected optimizer: " + $(optimizer))
}

val initialCoefWithIntercept = Vectors.zeros(numFeaturesPlusIntercept)

val states = optimizer.iterations(new CachedDiffFunction(costFun),
val states = optimizerAlgo.iterations(new CachedDiffFunction(costFun),
initialCoefWithIntercept.asBreeze.toDenseVector)

val scaledObjectiveHistory = mutable.ArrayBuilder.make[Double]
var state: optimizer.State = null
var state: optimizerAlgo.State = null
while (states.hasNext) {
state = states.next()
scaledObjectiveHistory += state.adjustedValue
Expand Down Expand Up @@ -258,6 +292,15 @@ class LinearSVC @Since("2.2.0") (
@Since("2.2.0")
object LinearSVC extends DefaultParamsReadable[LinearSVC] {

/** String name for Limited-memory BFGS. */
private[classification] val LBFGS: String = "lbfgs".toLowerCase(Locale.ROOT)

/** String name for Orthant-Wise Limited-memory Quasi-Newton. */
private[classification] val OWLQN: String = "owlqn".toLowerCase(Locale.ROOT)

/* Set of optimizers that LinearSVC supports */
private[classification] val supportedOptimizers = Array(LBFGS, OWLQN)

@Since("2.2.0")
override def load(path: String): LinearSVC = super.load(path)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,21 +75,25 @@ class LinearSVCSuite extends SparkFunSuite with MLlibTestSparkContext with Defau
}

test("Linear SVC binary classification") {
val svm = new LinearSVC()
val model = svm.fit(smallBinaryDataset)
assert(model.transform(smallValidationDataset)
.where("prediction=label").count() > nPoints * 0.8)
val sparseModel = svm.fit(smallSparseBinaryDataset)
checkModels(model, sparseModel)
LinearSVC.supportedOptimizers.foreach { opt =>
val svm = new LinearSVC().setOptimizer(opt)
val model = svm.fit(smallBinaryDataset)
assert(model.transform(smallValidationDataset)
.where("prediction=label").count() > nPoints * 0.8)
val sparseModel = svm.fit(smallSparseBinaryDataset)
checkModels(model, sparseModel)
}
}

test("Linear SVC binary classification with regularization") {
val svm = new LinearSVC()
val model = svm.setRegParam(0.1).fit(smallBinaryDataset)
assert(model.transform(smallValidationDataset)
.where("prediction=label").count() > nPoints * 0.8)
val sparseModel = svm.fit(smallSparseBinaryDataset)
checkModels(model, sparseModel)
LinearSVC.supportedOptimizers.foreach { opt =>
val svm = new LinearSVC().setOptimizer(opt).setMaxIter(10)
val model = svm.setRegParam(0.1).fit(smallBinaryDataset)
assert(model.transform(smallValidationDataset)
.where("prediction=label").count() > nPoints * 0.8)
val sparseModel = svm.fit(smallSparseBinaryDataset)
checkModels(model, sparseModel)
}
}

test("params") {
Expand All @@ -112,6 +116,7 @@ class LinearSVCSuite extends SparkFunSuite with MLlibTestSparkContext with Defau
assert(lsvc.getFeaturesCol === "features")
assert(lsvc.getPredictionCol === "prediction")
assert(lsvc.getRawPredictionCol === "rawPrediction")
assert(lsvc.getOptimizer === "lbfgs")
val model = lsvc.setMaxIter(5).fit(smallBinaryDataset)
model.transform(smallBinaryDataset)
.select("label", "prediction", "rawPrediction")
Expand Down Expand Up @@ -154,22 +159,23 @@ class LinearSVCSuite extends SparkFunSuite with MLlibTestSparkContext with Defau

test("linearSVC with sample weights") {
def modelEquals(m1: LinearSVCModel, m2: LinearSVCModel): Unit = {
assert(m1.coefficients ~== m2.coefficients absTol 0.05)
assert(m1.coefficients ~== m2.coefficients absTol 0.07)
assert(m1.intercept ~== m2.intercept absTol 0.05)
}

val estimator = new LinearSVC().setRegParam(0.01).setTol(0.01)
val dataset = smallBinaryDataset
MLTestingUtils.testArbitrarilyScaledWeights[LinearSVCModel, LinearSVC](
dataset.as[LabeledPoint], estimator, modelEquals)
MLTestingUtils.testOutliersWithSmallWeights[LinearSVCModel, LinearSVC](
dataset.as[LabeledPoint], estimator, 2, modelEquals, outlierRatio = 3)
MLTestingUtils.testOversamplingVsWeighting[LinearSVCModel, LinearSVC](
dataset.as[LabeledPoint], estimator, modelEquals, 42L)
LinearSVC.supportedOptimizers.foreach { opt =>
val estimator = new LinearSVC().setRegParam(0.02).setTol(0.01).setOptimizer(opt)
val dataset = smallBinaryDataset
MLTestingUtils.testArbitrarilyScaledWeights[LinearSVCModel, LinearSVC](
dataset.as[LabeledPoint], estimator, modelEquals)
MLTestingUtils.testOutliersWithSmallWeights[LinearSVCModel, LinearSVC](
dataset.as[LabeledPoint], estimator, 2, modelEquals, outlierRatio = 3)
MLTestingUtils.testOversamplingVsWeighting[LinearSVCModel, LinearSVC](
dataset.as[LabeledPoint], estimator, modelEquals, 42L)
}
}

test("linearSVC comparison with R e1071 and scikit-learn") {
val trainer1 = new LinearSVC()
test("linearSVC OWLQN comparison with R e1071 and scikit-learn") {
val trainer1 = new LinearSVC().setOptimizer("owlqn")
.setRegParam(0.00002) // set regParam = 2.0 / datasize / c

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.

Can I ask why we set regParam = 2.0 / datasize / c to match solution of sklearn.svm.LinearSVC? AFAIK, sklearn called liblinear to train linear SVM classification model, I can understand liblinear using C for penalty parameter of the error term which is different from regParam, and it doesn't multiply 1/n on the error term, but where is the 2.0 come from? Thanks.

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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This slides also explain it...Please see slide 32...the max can be replaced by soft-max with the softness lambda can be tuned...log-sum-exp is a standard soft-max that can be used which is similar to ReLu functions and we can re-use it from MLP:
ftp://ftp.cs.wisc.edu/math-prog/talks/informs99ssv.ps
ftp://ftp.cs.wisc.edu/pub/dmi/tech-reports/99-03.pdf
I can add the formulation if there is interest...it needs some tuning for soft-max parameter but the convergence will be good with LBFGS (OWLQN is not needed)

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.

@debasish83 Not sure if it's a better solution than squared hinge loss. But I would be interested to learn the performance (accuracy and speed). Can you please help try to evaluate it?

@debasish83 debasish83 May 10, 2017

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

hinge loss is not differentiable...how are you smoothing it before you can use a quasi newton solver ? Since the papers smooth the max, a newton/quasi-newton solver should work well...if you are keeping the non-differentiable loss better will be to use a sub-gradient solver as suggested by the talk...I will evaluate the formulation...

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

@hhbyyh I saw some posts that hinge loss is not differentiable but squared hinge loss is for practical purposes...can you please point to a reference on squared hinge loss ?

.setMaxIter(200)
.setTol(1e-4)
Expand Down Expand Up @@ -223,6 +229,25 @@ class LinearSVCSuite extends SparkFunSuite with MLlibTestSparkContext with Defau
assert(model1.coefficients ~== coefficientsSK relTol 4E-3)
}

test("linearSVC LBFGS comparison with R e1071 and scikit-learn") {
val trainer1 = new LinearSVC().setOptimizer("LBFGS")
.setRegParam(0.00003)

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.

Why we switch to different regParam compared with the above test? Does it means the converged solution will depends on optimizer?

@hhbyyh hhbyyh May 10, 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.

Indeed. I can use your help here since I cannot find the theory proof for this case.

LR may have the same behavior. Since if I set the optimizer of LR to OWLQN, all the L2 regularization cases will fail.

.setMaxIter(200)
.setTol(1e-4)
val model1 = trainer1.fit(binaryDataset)

// refer to last unit test for R and python code
val coefficientsR = Vectors.dense(7.310338, 14.89741, 22.21005, 29.83508)
val interceptR = 7.440177
assert(model1.intercept ~== interceptR relTol 2E-2)
assert(model1.coefficients ~== coefficientsR relTol 1E-2)

val coefficientsSK = Vectors.dense(7.24690165, 14.77029087, 21.99924004, 29.5575729)
val interceptSK = 7.36947518
assert(model1.intercept ~== interceptSK relTol 1E-2)
assert(model1.coefficients ~== coefficientsSK relTol 1E-2)
}

test("read/write: SVM") {
def checkModelData(model: LinearSVCModel, model2: LinearSVCModel): Unit = {
assert(model.intercept === model2.intercept)
Expand Down