感想
5.评估分类模型的性能
通常在二分类中使用的评估方法包括:预测正确率和错误率,准确率和召回率,准确率-召回率下方的面积,ROC曲线,ROC曲线下的面积和F-Measure.
5.1 预测的正确率和错误率
我们通过对输入特征进行预测并将预测值与实际标签进行比较,计算出模型在训练数据上的正确率。
val lrTotalCorrect = data.map { point =>
if (lrModel.predict(point.features) == point.label) 1 else 0
}.sum
val lrAccuracy = lrTotalCorrect / numData
val svmTotalCorrect = data.map { point =>
if (svmModel.predict(point.features) == point.label) 1 else 0
}.sum
val svmAccuracy = svmTotalCorrect / numData
val nbTotalCorrect = nbData.map { point =>
if (nbModel.predict(point.features) == point.label) 1 else 0
}.sum
val nbAccuracy = nbTotalCorrect / numData
val dtTotalCorrect = data.map { point =>
val score = dtModel.predict(point.features)
val predicted = if (score > 0.5) 1 else 0
if (predicted == point.label) 1 else 0
}.sum
val dtAccuracy = dtTotalCorrect / numData
运行结果和书上一致
5.2准去率和召回率
在信息检索中,准确率通常用于评价结果的质量,而召回率用来评价结果的完整性。
在二分类问题中,准确率定义为真阳性的数目处以真阳性和假阳性的总数,其中真阳性是指被正确预测的类别为1的样本,假阳性是错误预测为类别1的样本。如果每个分类器预测为类别1的样本确实属于类别1,那准确率达到100%。
召回率定义为真阳性的数目除以真阳性和假阴性的和,其中假阴性是类别为1却被预测为0的样本。如果任何一个类型为1的样本没有被错误预测为类别0(即没有假阴性),那召回率达到100%。
通常,准确率和召回率是负相关的,高准确率常常对应低召回率,反之亦然。例如:假定我们训练了一个模型的预测输出永远是类别1.因为总是预测输出类别1,所以模型预测结果不会出现假阴性,这样也不会错过任何类别1的样本。于是,得到模型的召回率是1.0。另一方面,假阳性会非常高,意味着准确率非常低(这依赖各个类别在数据集中确切的分布情况)。
准确率和召回率在单独度量时用处不大,但是它们通常会被一起组成聚合或者平均度量。二者同时也依赖于模型中选择的阈值。
直觉上讲,当阈值低于某个程度,模型的预测结果永远会是类别1.因此,模型的召回率为1,但是准确率很可能很低。相反,当阈值足够大,模型的预测结果永远会是类别0.此时,模型的召回率为0,但是因为模型不能预测任何真阳性的样本,很可能会有很多的假阴性样本。不仅如此,因为这种情况下真阳性和假阳性为0,所以无法定义模型的准确率。
图5-8所示的PR曲线,表示给定模型随着决策阈值的改变,准确率和召回率的对应关系。PR曲线下的面积为平均准确率。直觉上,PR曲线下的面积为1等价于一个完美模型,其准确率和召回率达到100%。
5.3 ROC曲线和AUC
ROC曲线在概念上和PR曲线类似,它是对分类器的真阳性率-假阳性率的图形化解释。
真阳性率(TPR)是真阳性的样本数除以真阳性和假阴性的样本数之和。即TPR是真阳性数目占所有正样本的比例。这和召回率类似,通常也称为敏感度。
假阳性率(FPR)是假阳性的样本数除以假阳性和真阴性的样本数之和。即FPR是假阳性样本数占所有负样本总数的比例。
ROC曲线如图,表示了分类器性能在不同决策阈值下TPR对FPR的折衷。曲线上每个点代表分类器决策函数中不同的阈值。
ROC下的面积表示平均值。同样,AUC为1.0时表示一个完美的分类器,0.5则表示一个随机的性能。于是,一个模型的AUC为0.5时和随机猜测效果一样。import org.apache.spark.mllib.evaluation.BinaryClassificationMetrics
//SVM和LR
val metrics = Seq(lrModel, svmModel).map { model =>
val scoreAndLabels = data.map { point =>
// println("point.features:"+point.features+"\t label:"+point.label)
val predict=model.predict(point.features)
//println("predict:"+predict)
(model.predict(point.features), point.label)
}
//println(">>>>>>>>>>>"+scoreAndLabels)
val metrics = new BinaryClassificationMetrics(scoreAndLabels)
(model.getClass.getSimpleName, metrics.areaUnderPR, metrics.areaUnderROC)
}
//朴素贝叶斯
val nbMetrics = Seq(nbModel).map { model =>
val scoreAndLabels = nbData.map { point =>
val score = model.predict(point.features)
(if (score > 0.5) 1.0 else 0.0, point.label)
}
val metrics = new BinaryClassificationMetrics(scoreAndLabels)
(model.getClass.getSimpleName, metrics.areaUnderPR, metrics.areaUnderROC)
}
//决策树
val dtMetrics = Seq(dtModel).map { model =>
val scoreAndLabels = data.map { point =>
val score = model.predict(point.features)
(if (score > 0.5) 1.0 else 0.0, point.label)
}
val metrics = new BinaryClassificationMetrics(scoreAndLabels)
(model.getClass.getSimpleName, metrics.areaUnderPR, metrics.areaUnderROC)
}
//val allMetrics: Seq[(String, Double, Double)]
val allMetrics = metrics ++ nbMetrics ++ dtMetrics
allMetrics.foreach {
case (m, pr, roc) =>
println(f"$m, Area under PR: ${pr * 100.0}%2.4f%%, Area under ROC: ${roc * 100.0}%2.4f%%")
}
6.改进模型性能以及参数调优
6.1特征标准化
我们使用的许多模型对输入数据的分布和规模有着一些固有的假设,其中最常见的假设形式是特征满足正态分布。
computeColumnSummaryStatistics方法计算特征矩阵每列的不同统计数据,包括均值和方差,所有统计值按每列一项的方式存储一个Vector中。
import org.apache.spark.mllib.linalg.distributed.RowMatrix
val vectors = data.map {
lp =>
println("lp.features:" + lp.features)
lp.features
}
val matrix = new RowMatrix(vectors)
val matrixSummary = matrix.computeColumnSummaryStatistics()
println(matrixSummary.mean)
println(matrixSummary.min)
println(matrixSummary.max)
println(matrixSummary.variance)
println(matrixSummary.numNonzeros)
我们的数据在原始形式下,确切地说并不符合标准的高斯分布。为使数据更符合模型的假设,可以对每个特征进行标准化,使得每个特征是0均值和单位标准差。具体为:
实际上,我们可以对数据集中每个特征向量,与均值向量按项依次做减法,然后依次按项除以特征的标准差向量。
val scaler = new StandardScaler(withMean = true, withStd = true).fit(vectors)
val scaledData = data.map(lp => LabeledPoint(lp.label, scaler.transform(lp.features)))
// compare the raw features with the scaled features
println(data.first.features)
println(scaledData.first.features)
println((0.789131 - 0.41225805299526636) / math.sqrt(0.1097424416755897))
// 1.137647336497682
val lrModelScaled = LogisticRegressionWithSGD.train(scaledData, numIterations)
val lrTotalCorrectScaled = scaledData.map { point =>
if (lrModelScaled.predict(point.features) == point.label) 1 else 0
}.sum
val lrAccuracyScaled = lrTotalCorrectScaled / numData
// lrAccuracyScaled: Double = 0.6204192021636241
val lrPredictionsVsTrue = scaledData.map { point =>
(lrModelScaled.predict(point.features), point.label)
}
val lrMetricsScaled = new BinaryClassificationMetrics(lrPredictionsVsTrue)
val lrPr = lrMetricsScaled.areaUnderPR
val lrRoc = lrMetricsScaled.areaUnderROC
println(f"${lrModelScaled.getClass.getSimpleName}\n Accuracy: ${lrAccuracyScaled * 100}%2.4f%%\nArea under PR: ${lrPr * 100.0}%2.4f%%\nArea under ROC: ${lrRoc * 100.0}%2.4f%%")
6.2 其他特征
我们已经看到,需要注意对特征进行标准和归一化,这对模型性能可能有重要影响。在这个示例中,我们仅仅使用了部分特征,却完全忽略了类别变量(category)和样板(boilerplate)列的文本内容。这样做是为了便于介绍。现在我们再来评估一下添加其他特征,比如类别特征对性能的影响。
val categories = records.map(r => r(3)).distinct.collect.zipWithIndex.toMap
val numCategories = categories.size
println(categories)
println(numCategories)
val dataCategories = records.map { r =>
val trimmed = r.map(_.replaceAll("\"", ""))
val label = trimmed(r.size - 1).toInt
val categoryIdx = categories(r(3))
val categoryFeatures = Array.ofDim[Double](numCategories)
categoryFeatures(categoryIdx) = 1.0
val otherFeatures = trimmed.slice(4, r.size - 1).map(d => if (d == "?") 0.0 else d.toDouble)
val features = categoryFeatures ++ otherFeatures
LabeledPoint(label, Vectors.dense(features))
}
println(dataCategories.first)
val scalerCats = new StandardScaler(withMean = true, withStd = true).fit(dataCategories.map(lp => lp.features))
val scaledDataCats = dataCategories.map(lp => LabeledPoint(lp.label, scalerCats.transform(lp.features)))
println(dataCategories.first.features)
// [0.0,1.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.789131,2.055555556,0.676470588,0.205882353,
// 0.047058824,0.023529412,0.443783175,0.0,0.0,0.09077381,0.0,0.245831182,0.003883495,1.0,1.0,24.0,0.0,
// 5424.0,170.0,8.0,0.152941176,0.079129575]
println(scaledDataCats.first.features)
虽然原始特征是稀疏的,但对每个项减去均值之后,将得到一个非稀疏的特征向量表示,如上面的例子。
数据规模比较小的时候,稀疏的的特征不会产生问题,但实践中往往大规模数据是非常稀疏的(比如在线广告和文本分类)。此时,不建议丢失数据的稀疏性,因为相应的稠密表示所需要的内存和计算量将爆炸性增长。这时我们可以将StandardScalar的withMean设置为false来避免这个问题。
现在可以用扩展后的特征来训练新的逻辑回归模型了,然后我们再评估其性能。
val lrModelScaledCats = LogisticRegressionWithSGD.train(scaledDataCats, numIterations)
val lrTotalCorrectScaledCats = scaledDataCats.map { point =>
if (lrModelScaledCats.predict(point.features) == point.label) 1 else 0
}.sum
val lrAccuracyScaledCats = lrTotalCorrectScaledCats / numData
val lrPredictionsVsTrueCats = scaledDataCats.map { point =>
(lrModelScaledCats.predict(point.features), point.label)
}
val lrMetricsScaledCats = new BinaryClassificationMetrics(lrPredictionsVsTrueCats)
val lrPrCats = lrMetricsScaledCats.areaUnderPR
val lrRocCats = lrMetricsScaledCats.areaUnderROC
println(f"${lrModelScaledCats.getClass.getSimpleName}\nAccuracy: ${lrAccuracyScaledCats * 100}%2.4f%%\nArea under PR: ${lrPrCats * 100.0}%2.4f%%\nArea under ROC: ${lrRocCats * 100.0}%2.4f%%")
6.3 使用正确的数据格式
模型性能的另外一个关键部分是对每个模型使用正确的数据格式。MLib实现了多项式模型,并且该模型可以处理计数形式的数据。这包括二元表示的类型特征(如1-of-k)或者频率数据(如一个文档中单词出现的频率)。我开始时使用的数值特征并不符合假定的输入分布,所以模型性能不好也并不是意料之外。
val dataNB = records.map { r =>
val trimmed = r.map(_.replaceAll("\"", ""))
val label = trimmed(r.size - 1).toInt
val categoryIdx = categories(r(3))
val categoryFeatures = Array.ofDim[Double](numCategories)
categoryFeatures(categoryIdx) = 1.0
LabeledPoint(label, Vectors.dense(categoryFeatures))
}
val nbModelCats = NaiveBayes.train(dataNB)
val nbTotalCorrectCats = dataNB.map { point =>
if (nbModelCats.predict(point.features) == point.label) 1 else 0
}.sum
val nbAccuracyCats = nbTotalCorrectCats / numData
val nbPredictionsVsTrueCats = dataNB.map { point =>
(nbModelCats.predict(point.features), point.label)
}
val nbMetricsCats = new BinaryClassificationMetrics(nbPredictionsVsTrueCats)
val nbPrCats = nbMetricsCats.areaUnderPR
val nbRocCats = nbMetricsCats.areaUnderROC
println(f"${nbModelCats.getClass.getSimpleName}\nAccuracy: ${nbAccuracyCats * 100}%2.4f%%\nArea under PR: ${nbPrCats * 100.0}%2.4f%%\nArea under ROC: ${nbRocCats * 100.0}%2.4f%%")
6.4 模型参数调优
前面展示了模型性能的影响因素:特征提取,特征选择,输入数据的格式和模型对数据分布的假设。实际上模型参数对模型性能影响很大。
1.线性模型
逻辑回归和SVM模型有相同的参数,因为她们都使用SGD作为基础优化技术。不同点在于二者采用的损失函数不同。
import org.apache.spark.rdd.RDD
import org.apache.spark.mllib.optimization.Updater
import org.apache.spark.mllib.optimization.SimpleUpdater
import org.apache.spark.mllib.optimization.L1Updater
import org.apache.spark.mllib.optimization.SquaredL2Updater
import org.apache.spark.mllib.classification.ClassificationModel
def trainWithParams(input: RDD[LabeledPoint], regParam: Double, numIterations: Int, updater: Updater, stepSize: Double) = {
val lr = new LogisticRegressionWithSGD
lr.optimizer.setNumIterations(numIterations).setUpdater(updater).setRegParam(regParam).setStepSize(stepSize)
lr.run(input)
}
def createMetrics(label: String, data: RDD[LabeledPoint], model: ClassificationModel) = {
val scoreAndLabels = data.map { point =>
(model.predict(point.features), point.label)
}
val metrics = new BinaryClassificationMetrics(scoreAndLabels)
(label, metrics.areaUnderROC)
}
scaledDataCats.cache
(1)迭代
大多数机器学习的方法需要迭代训练,并且经过一定次数的迭代之后收敛到某个解(即最小化损失函数时的最优权重向量)。SGD收敛到合适的解需要迭代的次数相对较少,但是要进一步提升性能则需要更多次迭代。
val iterResults = Seq(1, 5, 10, 50).map { param =>
val model = trainWithParams(scaledDataCats, 0.0, param, new SimpleUpdater, 1.0)
createMetrics(s"$param iterations", scaledDataCats, model)
}
iterResults.foreach { case (param, auc) => println(f"$param, AUC = ${auc * 100}%2.2f%%") }
一旦完成特定次数的迭代,再增大迭代次数对结果的影响较小。
(2)步长
在SGD中,在训练每个样本并更新模型的权重向量时,步长用来控制算法在最陡峭的梯度方向上应该前进多远。较大的步长收敛较快,但是步长太大可能导致收敛到局部最优解。
val stepResults = Seq(0.001, 0.01, 0.1, 1.0, 10.0).map { param =>
val model = trainWithParams(scaledDataCats, 0.0, numIterations, new SimpleUpdater, param)
createMetrics(s"$param step size", scaledDataCats, model)
}
stepResults.foreach { case (param, auc) => println(f"$param, AUC = ${auc * 100}%2.2f%%") }
实验结果表明步长增长过大对性能有负面影响。
(3)正则化
前面逻辑回归的代码中简单提及了Updater类,该类在MLib中实现了正则化。正则化通过限制模型的复杂度避免模型在训练数据中过拟合。
正则化的具体做法是在损失函数中添加一项关于模型权重向量的函数,从而会使损失增加。正则化在现实中几乎是必须的,当特征维度高于训练样本时(此时变量相关需要学习的权重数量也非常大)尤其重要。
当正则化不存在或者非常低时,模型容易过拟合。而且大多数模型在没有正则化的情况会在训练数据上过拟合。过拟合也是交叉验证技术使用的关键原因,交叉验证会在后面详细介绍。
相反,正虽然正则化可以得到一个简单模型,但正则化太高可能导致模型的欠拟合,从而使模型性能变得很糟糕。
Mlib中可用的正则化形式有如下几个:
· SimpleUpdater :相当于没有正则化,是逻辑回归的默认配置。
· SquaredL2Updater :这个正则项基于权重向量的L2正则化,是SVM模型的默认值。
· L1Updater :这个正则项基于权重向量的L1正则化,会导致得到一个稀疏的权重向量(不重要的权重的值接近0)。
val regResults = Seq(0.001, 0.01, 0.1, 1.0, 10.0).map { param =>
val model = trainWithParams(scaledDataCats, param, numIterations, new SquaredL2Updater, 1.0)
createMetrics(s"$param L2 regularization parameter", scaledDataCats, model)
}
regResults.foreach { case (param, auc) => println(f"$param, AUC = ${auc * 100}%2.2f%%") }
低等级的正则化对模型的性能影响不大。然而,增大正则化可以看到欠拟合会导致较低模型性能。
2.决策树
决策树在一开始使用原始数据做训练时获得了最好的性能。当时设置了参数maxDepth用来控制决策树的最大深度,进而控制模型的复杂度。而树的深度越大,得到的模型越复杂,但有能力更好地拟合数据。
对于分类问题,我们需要为决策树模型选择以下两种不纯度度量方式:Gini或者Entropy。
树的深度和不纯度调优:
通过使用Entropy不纯度并改变树的深度训练模型:
import org.apache.spark.mllib.tree.impurity.Entropy
import org.apache.spark.mllib.tree.impurity.Gini
import org.apache.spark.mllib.tree.impurity.Impurity
def trainDTWithParams(input: RDD[LabeledPoint], maxDepth: Int, impurity: Impurity) = {
DecisionTree.train(input, Algo.Classification, impurity, maxDepth)
}
val dtResultsEntropy = Seq(1, 2, 3, 4, 5, 10, 20).map { param =>
val model = trainDTWithParams(data, param, Entropy)
val scoreAndLabels = data.map { point =>
val score = model.predict(point.features)
(if (score > 0.5) 1.0 else 0.0, point.label)
}
val metrics = new BinaryClassificationMetrics(scoreAndLabels)
(s"$param tree depth", metrics.areaUnderROC)
}
dtResultsEntropy.foreach { case (param, auc) => println(f"$param, AUC = ${auc * 100}%2.2f%%") }
采用Gini不纯度进行类似计算:
val dtResultsGini = Seq(1, 2, 3, 4, 5, 10, 20).map { param =>
val model = trainDTWithParams(data, param, Gini)
val scoreAndLabels = data.map { point =>
val score = model.predict(point.features)
(if (score > 0.5) 1.0 else 0.0, point.label)
}
val metrics = new BinaryClassificationMetrics(scoreAndLabels)
(s"$param tree depth", metrics.areaUnderROC)
}
dtResultsGini.foreach { case (param, auc) => println(f"$param, AUC = ${auc * 100}%2.2f%%") }
提高树的深度可以得到更精确的模型。然而树的深度越大,模型对训练数据过拟合程度越严重。两种不纯度方法对性能的影响差异较小。
3.朴素贝叶斯
Lamda参数可以控制相加式平滑,解决数据中某个类别和某个特征值的组合没有同时出现的问题。
def trainNBWithParams(input: RDD[LabeledPoint], lambda: Double) = {
val nb = new NaiveBayes
nb.setLambda(lambda)
nb.run(input)
}
val nbResults = Seq(0.001, 0.01, 0.1, 1.0, 10.0).map { param =>
val model = trainNBWithParams(dataNB, param)
val scoreAndLabels = dataNB.map { point =>
(model.predict(point.features), point.label)
}
val metrics = new BinaryClassificationMetrics(scoreAndLabels)
(s"$param lambda", metrics.areaUnderROC)
}
nbResults.foreach { case (param, auc) => println(f"$param, AUC = ${auc * 100}%2.2f%%") }
实验结果表明lambda的值对性能没有影响,由此可见数据中某个特征和某个类别的组合不存在时不是问题。
4.交叉验证
交叉验证的目的是测试模型在未知数据上的性能。不知道的模型在预测新数据时的性能,而直接放在实际数据(比如运行的系统)中进行评估是很危险的做法。正如前面提到的正则化实验中,我们的模型可能在训练数据中已经过拟合了,于是在未被训练的新数据中预测性能会很差。
交叉验证让我们使用一部分数据训练模型,将另外一部分用来评估模型性能。如果模型在训练以外的新数据中进行了测试,我们便可以由此估计模型对新数据的泛化能力。
我们把数据划分为训练和测试数据,实现一个简单的交叉验证过程。我们将数据分为两个不重叠的数据集。第一个数据集用来训练,称为训练集。第二个数据集称为测试集或者保留集,用来评估模型在给定评测方法下的性能。实际中常用的划分方法包括:50/50,60/40,80/20等,只要训练模型的数据量不太小就行。
一般会创建三个数据集:训练集,评估集和测试集。
val trainTestSplit = scaledDataCats.randomSplit(Array(0.6, 0.4), 123)
val train = trainTestSplit(0)
val test = trainTestSplit(1)
train.cache
test.cache
val regResultsTest = Seq(0.0, 0.001, 0.0025, 0.005, 0.01).map { param =>
val model = trainWithParams(train, param, numIterations, new SquaredL2Updater, 1.0)
createMetrics(s"$param L2 regularization parameter", test, model)
}
regResultsTest.foreach { case (param, auc) => println(f"$param, AUC = ${auc * 100}%2.6f%%") }
val regResultsTrain = Seq(0.0, 0.001, 0.0025, 0.005, 0.01).map { param =>
val model = trainWithParams(train, param, numIterations, new SquaredL2Updater, 1.0)
createMetrics(s"$param L2 regularization parameter", train, model)
}
regResultsTrain.foreach { case (param, auc) => println(f"$param, AUC = ${auc * 100}%2.6f%%") }
上面代码的运行结果可得,当我们的训练集和测试集相同时,通常在正则化参数比较小的情况下可以得到最高的性能。因为模型在较低的正则化下学习了所有的数据,即过拟合的情况下达到更高的性能。相反,当训练集和测试集不同时,通常较高正则化可以得到较高的测试性能。
参考文献
[1]. 机器学习基石(林轩田)(39). https://www.bilibili.com/video/av12463015/index_39.html#page=39