从在线存储库下载房屋数据集(housing.data)(GitHub),并使用以下代码加载数据:
val housingLines = sc.textFile("first-edition/ch07/housing.data", 6)
val housingVals = housingLines.map(x => Vectors.dense(x.split(",").map(_.trim().toDouble)))
为housingLines RDD使用了6个分区,但是可以根据集群环境选择其他值。现在已将数据解析并作为Vector对象使用。
要了解数据,可以先对它进行汇总,可以从相应的RowMatrix对象获取该值:
val housingMat = new RowMatrix(housingVals)
val housingStats = housingMat.computeColumnSummaryStatistics()
housingStats.min
或者可以使用Statistics对象达到目的:
import org.apache.spark.mllib.stat.Statistics
val housingStats=Statistics.colStats(housingVals)
然后可以使用获取的MultivariateStatisticalSummary对象来检查矩阵每列中的平均值(mean)、最大值(max)和最小值(min),使用normL1和normL2方法获取每个列的L1范数和L2范数,使用variance方法获得每列的方差。
方差:是数据离散程度的度量,等于所有值与它们的平均值的平方差的均值。
标准差:是方差的二次方根。
协方差:是衡量两个变量相关的程度。
val housingColSims = housingMat.columnSimilarities()
//UTILITY METHOD FOR PRETTY-PRINTING MATRICES
def printMat(mat:BM[Double]) = {
print(" ")
for(j <- 0 to mat.cols-1)
print("%-10d".format(j));
println
for(i <- 0 to mat.rows-1) {
print("%-6d".format(i));
for(j <- 0 to mat.cols-1)
print(" %+9.3f".format(mat(i, j)));
println
}
}
printMat(toBreezeD(housingColSims))
/* SHOULD GIVE:
0 1 2 3 4 5 6 7 8 9 10 11 12 13
0 +0,000 +0,004 +0,527 +0,052 +0,459 +0,363 +0,482 +0,169 +0,675 +0,563 +0,416 +0,288 +0,544 +0,224
1 +0,000 +0,000 +0,122 +0,078 +0,334 +0,467 +0,211 +0,673 +0,135 +0,297 +0,394 +0,464 +0,200 +0,528
2 +0,000 +0,000 +0,000 +0,256 +0,915 +0,824 +0,916 +0,565 +0,840 +0,931 +0,869 +0,779 +0,897 +0,693
3 +0,000 +0,000 +0,000 +0,000 +0,275 +0,271 +0,275 +0,184 +0,190 +0,230 +0,248 +0,266 +0,204 +0,307
4 +0,000 +0,000 +0,000 +0,000 +0,000 +0,966 +0,962 +0,780 +0,808 +0,957 +0,977 +0,929 +0,912 +0,873
5 +0,000 +0,000 +0,000 +0,000 +0,000 +0,000 +0,909 +0,880 +0,719 +0,906 +0,982 +0,966 +0,832 +0,949
6 +0,000 +0,000 +0,000 +0,000 +0,000 +0,000 +0,000 +0,672 +0,801 +0,929 +0,930 +0,871 +0,918 +0,803
7 +0,000 +0,000 +0,000 +0,000 +0,000 +0,000 +0,000 +0,000 +0,485 +0,710 +0,856 +0,882 +0,644 +0,856
8 +0,000 +0,000 +0,000 +0,000 +0,000 +0,000 +0,000 +0,000 +0,000 +0,917 +0,771 +0,642 +0,806 +0,588
9 +0,000 +0,000 +0,000 +0,000 +0,000 +0,000 +0,000 +0,000 +0,000 +0,000 +0,939 +0,854 +0,907 +0,789
10 +0,000 +0,000 +0,000 +0,000 +0,000 +0,000 +0,000 +0,000 +0,000 +0,000 +0,000 +0,957 +0,887 +0,897
11 +0,000 +0,000 +0,000 +0,000 +0,000 +0,000 +0,000 +0,000 +0,000 +0,000 +0,000 +0,000 +0,799 +0,928
12 +0,000 +0,000 +0,000 +0,000 +0,000 +0,000 +0,000 +0,000 +0,000 +0,000 +0,000 +0,000 +0,000 +0,670
13 +0,000 +0,000 +0,000 +0,000 +0,000 +0,000 +0,000 +0,000 +0,000 +0,000 +0,000 +0,000 +0,000 +0,000
housingColSims 是包含上三角矩阵(上三角矩阵包含仅在其对角线之上的数据)的分布式CoordinateMatrix,其第i行和第j列的值给出了HousingMat矩阵中第i列和第j列之间的相似度度量。housingColSims 矩阵中值的范围是-1到1,值为-1表示两列具有完全相反的方向,值为0表示它们正交,值为1表示它们方向相同。
用于检查输入集的不同列之间的相似性。可以使用RowMatrix对象来计算
val housingCovar = housingMat.computeCovariance()
printMat(toBreezeM(housingCovar))
0 1 2 3 4 5 6 7 8 9 10 11 12 13
0 +73,987 -40,216 +23,992 -0,122 +0,420 -1,325 +85,405 -6,877 +46,848 +844,822 +5,399 -302,382 +27,986 -30,719
1 -40,216 +543,937 -85,413 -0,253 -1,396 +5,113 -373,902 +32,629 -63,349 -1236,454 -19,777 +373,721 -68,783 +77,315
2 +23,992 -85,413 +47,064 +0,110 +0,607 -1,888 +124,514 -10,228 +35,550 +833,360 +5,692 -223,580 +29,580 -30,521
3 -0,122 -0,253 +0,110 +0,065 +0,003 +0,016 +0,619 -0,053 -0,016 -1,523 -0,067 +1,131 -0,098 +0,409
4 +0,420 -1,396 +0,607 +0,003 +0,013 -0,025 +2,386 -0,188 +0,617 +13,046 +0,047 -4,021 +0,489 -0,455
5 -1,325 +5,113 -1,888 +0,016 -0,025 +0,494 -4,752 +0,304 -1,284 -34,583 -0,541 +8,215 -3,080 +4,493
6 +85,405 -373,902 +124,514 +0,619 +2,386 -4,752 +792,358 -44,329 +111,771 +2402,690 +15,937 -702,940 +121,078 -97,589
7 -6,877 +32,629 -10,228 -0,053 -0,188 +0,304 -44,329 +4,434 -9,068 -189,665 -1,060 +56,040 -7,473 +4,840
8 +46,848 -63,349 +35,550 -0,016 +0,617 -1,284 +111,771 -9,068 +75,816 +1335,757 +8,761 -353,276 +30,385 -30,561
9 +844,822 -1236,454 +833,360 -1,523 +13,046 -34,583 +2402,690 -189,665 +1335,757 +28404,759 +168,153 -6797,911 +654,715 -726,256
10 +5,399 -19,777 +5,692 -0,067 +0,047 -0,541 +15,937 -1,060 +8,761 +168,153 +4,687 -35,060 +5,783 -10,111
11 -302,382 +373,721 -223,580 +1,131 -4,021 +8,215 -702,940 +56,040 -353,276 -6797,911 -35,060 +8334,752 -238,668 +279,990
12 +27,986 -68,783 +29,580 -0,098 +0,489 -3,080 +121,078 -7,473 +30,385 +654,715 +5,783 -238,668 +50,995 -48,448
13 -30,719 +77,315 -30,521 +0,409 -0,455 +4,493 -97,589 +4,840 -30,561 -726,256 -10,111 +279,990 -48,448 +84,587
方差-协方差矩阵的对角线上的值是每列的方差,其他位置上的值代表两个匹配列的协方差。如果两列的协方差为0,则它们不存在线性关系。若为负值,为负相关,正值则为正相关。
检查完数据之后,可以对数据进行线性回归了,首先需要将数据集中的每一个示例放在一个称为LabeledPoint的结构中,这个结构在Spark的机器学习中比较常用,它包含标签Label和特征Vector。创建LabeledPoint时需要将特征和标签分开
import org.apache.spark.mllib.regression.LabeledPoint
val housingData = housingVals.map(x => {
val a = x.toArray;
LabeledPoint(a(a.length-1), Vectors.dense(a.slice(0, a.length-1)))
})
接着将数据拆分成训练集和验证集
val sets = housingData.randomSplit(Array(0.8, 0.2))
val housingTrain = sets(0)
val housingValid = sets(1)
检查数据的分布时,列之间的数据跨度有很大的差异,比如第一列中的数据0.00632 ~ 88.9762,第五列的数据0.385 ~ 0.871,这样的数据可能会让损失函数在向最低点收敛时遇到困难,或者是其结果会被某一维度的特征异常地影响。
特征缩放意味着将数据范围缩放到可比较的大小。归一化意味着数据平均值大致为0。做法是首先用数据来创建一个缩放器。
import org.apache.spark.mllib.feature.StandardScaler
val scaler = new StandardScaler(true, true).fit(housingTrain.map(x => x.features))
然后将这个缩放器应用到数据上。
val trainScaled = housingTrain.map(x => LabeledPoint(x.label, scaler.transform(x.features)))
val validScaled = housingValid.map(x => LabeledPoint(x.label, scaler.transform(x.features)))
Spark中的线性回归模型由org.apache.spark.mllib.regression包中的LinearRegressionModel类实现。当使用数据拟合了一个模型对象后,可以使用其对各个Vector示例进行预测,方法是predict。
import org.apache.spark.mllib.regression.LinearRegressionWithSGD
val alg = new LinearRegressionWithSGD()
alg.setIntercept(true)
alg.optimizer.setNumIterations(200)
trainScaled.cache()
validScaled.cache()
val model = alg.run(trainScaled)
模型训练完毕后,就可以使用它来预测验证集中的数据,验证集的数据是有label的,所以预测出来的label可以与已有的label一起使用,进行比较。
val validPredicts = validScaled.map(x => (model.predict(x.features), x.label))
validPredicts.collect()
通过检查可以发现,一些预测与原始标签很近,有的则差很多。为了量化模型的有效性,可以计算均方根误差。
val RMSE = math.sqrt(validPredicts.map{case(p,l) => math.pow(p-l,2)}.mean())
Spark提供了RegressionMetrics类来对回归模型的性能进行评估,它返回几个有用的评估指标。
import org.apache.spark.mllib.evaluation.RegressionMetrics
val validMetrics = new RegressionMetrics(validPredicts)
validMetrics.rootMeanSquaredError
validMetrics.meanSquaredError
除了上面这些,还有meanAbsoluteError,r2,explaineddVariance。
模型训练之后的权重集可以告诉我们单个维度对目标变量的影响。如果特定的权重接近于0,则相应的维度对label的影响就不会很显著。(这要在数据已经进行过缩放的前提上)
println(model.weights.toArray.map(x => x.abs).zipWithIndex.sortBy(_._1).mkString(", "))
Spark提供了一种将模型保存到文件系统的方法(Parquet),并且在有需要的时候加载它。
model.save(sc, "hdfs:///path/to/saved/model")
import org.apache.spark.mllib.regression.LinearRegressionModel
val model = LinearRegressionModel.load(sc, "hdfs:///path/to/saved/model")
公式中的参数r是步长参数,有助于稳定梯度下降算法。此外还有迭代次数,如果迭代次数太大,则模型拟合会花费太多时间,如果它太小,则算法可能达不到最小值。
找到这两个参数的方法是多长尝试几个组合,并找到最好的结果。
import org.apache.spark.rdd.RDD
def iterateLRwSGD(iterNums:Array[Int], stepSizes:Array[Double], train:RDD[LabeledPoint], test:RDD[LabeledPoint]) = {
for(numIter <- iterNums; step <- stepSizes)
{
val alg = new LinearRegressionWithSGD()
alg.setIntercept(true).optimizer.setNumIterations(numIter).setStepSize(step)
val model = alg.run(train)
val rescaledPredicts = train.map(x => (model.predict(x.features), x.label))
val validPredicts = test.map(x => (model.predict(x.features), x.label))
val meanSquared = math.sqrt(rescaledPredicts.map({case(p,l) => math.pow(p-l,2)}).mean())
val meanSquaredValid = math.sqrt(validPredicts.map({case(p,l) => math.pow(p-l,2)}).mean())
println("%d, %5.3f -> %.4f, %.4f".format(numIter, step, meanSquared, meanSquaredValid))
//Uncomment if you wish to see weghts and intercept values:
//println("%d, %4.2f -> %.4f, %.4f (%s, %f)".format(numIter, step, meanSquared, meanSquaredValid, model.weights, model.intercept))
}
}
iterateLRwSGD(Array(200, 400, 600), Array(0.05, 0.1, 0.5, 1, 1.5, 2, 3), trainScaled, validScaled)
上面的代码返回训练和验证集的RMSE:
// Our results:
// 200, 0.050 -> 7.5420, 7.4786
// 200, 0.100 -> 5.0437, 5.0910
// 200, 0.500 -> 4.6920, 4.7814
// 200, 1.000 -> 4.6777, 4.7756
// 200, 1.500 -> 4.6751, 4.7761
// 200, 2.000 -> 4.6746, 4.7771
// 200, 3.000 -> 108738480856.3940, 122956877593.1419
// 400, 0.050 -> 5.8161, 5.8254
// 400, 0.100 -> 4.8069, 4.8689
// 400, 0.500 -> 4.6826, 4.7772
// 400, 1.000 -> 4.6753, 4.7760
// 400, 1.500 -> 4.6746, 4.7774
// 400, 2.000 -> 4.6745, 4.7780
// 400, 3.000 -> 25240554554.3096, 30621674955.1730
// 600, 0.050 -> 5.2510, 5.2877
// 600, 0.100 -> 4.7667, 4.8332
// 600, 0.500 -> 4.6792, 4.7759
// 600, 1.000 -> 4.6748, 4.7767
// 600, 1.500 -> 4.6745, 4.7779
// 600, 2.000 -> 4.6745, 4.7783
// 600, 3.000 -> 4977766834.6285, 6036973314.0450
通过上面的结果可以得到步长为1,使用200次会更适合。
通常情况下,数据不遵循简单的线性公式,可能是某种曲线,曲线通常可以使用高阶多项式来描述。
Spark不提供训练非线性回归模型的方法,但是可以通过将现有特征相乘所获得的附加特征来扩展数据集:
def addHighPols(v:Vector): Vector =
{
Vectors.dense(v.toArray.flatMap(x => Array(x, x*x)))
}
val housingHP = housingData.map(v => LabeledPoint(v.label, addHighPols(v.features)))
查看housingHP RDD,扩展了附加特征,总共是26个特征
housingHP.first().features.size
接着拆分数据集,缩放数据:
val setsHP = housingHP.randomSplit(Array(0.8, 0.2))
val housingHPTrain = setsHP(0)
val housingHPValid = setsHP(1)
val scalerHP = new StandardScaler(true, true).fit(housingHPTrain.map(x => x.features))
val trainHPScaled = housingHPTrain.map(x => LabeledPoint(x.label, scalerHP.transform(x.features)))
val validHPScaled = housingHPValid.map(x => LabeledPoint(x.label, scalerHP.transform(x.features)))
trainHPScaled.cache()
validHPScaled.cache()
估计最佳步长和迭代次数:
iterateLRwSGD(Array(200, 400), Array(0.4, 0.5, 0.6, 0.7, 0.9, 1.0, 1.1, 1.2, 1.3, 1.5), trainHPScaled, validHPScaled)
// Our results:
// 200, 0.400 -> 4.5423, 4.2002
// 200, 0.500 -> 4.4632, 4.1532
// 200, 0.600 -> 4.3946, 4.1150
// 200, 0.700 -> 4.3349, 4.0841
// 200, 0.900 -> 4.2366, 4.0392
// 200, 1.000 -> 4.1961, 4.0233
// 200, 1.100 -> 4.1605, 4.0108
// 200, 1.200 -> 4.1843, 4.0157
// 200, 1.300 -> 165.8268, 186.6295
// 200, 1.500 -> 182020974.1549, 186781045.5643
// 400, 0.400 -> 4.4117, 4.1243
// 400, 0.500 -> 4.3254, 4.0795
// 400, 0.600 -> 4.2540, 4.0466
// 400, 0.700 -> 4.1947, 4.0228
// 400, 0.900 -> 4.1032, 3.9947
// 400, 1.000 -> 4.0678, 3.9876
// 400, 1.100 -> 4.0378, 3.9836
// 400, 1.200 -> 4.0407, 3.9863
// 400, 1.300 -> 106.0047, 121.4576
// 400, 1.500 -> 162153976.4283, 163000519.6179
从结果可以看出,RMSE在1.3的位置会出现暴涨,在1.1的位置获得最佳结果,在400次迭代中获得最佳RMSE为3.9836。当使用更大的迭代次数时:
iterateLRwSGD(Array(200, 400, 800, 1000, 3000, 6000), Array(1.1), trainHPScaled, validHPScaled)
//Our results:
// 200, 1.100 -> 4.1605, 4.0108
// 400, 1.100 -> 4.0378, 3.9836
// 800, 1.100 -> 3.9438, 3.9901
// 1000, 1.100 -> 3.9199, 3.9982
// 3000, 1.100 -> 3.8332, 4.0633
// 6000, 1.100 -> 3.7915, 4.1138
此时会发现迭代次数越多,测试集的RMSE会增加,为什么会增加?应该选择哪个步长?
测试RMSE在训练RMSE降低的时候增加的情况称为过度拟合。表现是,模型过于适应训练集中的“噪声”,而在分析不具有训练集相同属性的新的数据时变得不太准确。另外还有一个术语:欠拟合,模型过于简单,无法充分捕捉数据的复杂性。
这导致了要进行偏差-方差的权衡。
首先需要知道模型是否具有较高的偏差(欠拟合)或高方差(过拟合)。通常,当模型复杂度和训练集大小的比率比较大时,会发生过度拟合。如果有一个复杂的模型,同时也有一个相对较大的训练集时,发生过度拟合的可能性就会比较低。所以结论是,加入高阶多项式给模型带来更多的复杂性,这样的模型在适合的迭代次数下可以很好的拟合数据(偏差),但是一旦迭代次数过多,就会出现过度拟合的情况(方差)。
残差图可以判断是否需要继续添加高阶多项式,或者是添加哪些多项式等等问题,残差是目标变量的预测值和实际值之间的差值,残差图的y轴是残差,x轴是预测值。
如果模型拟合得很好,那么残差图整体应该是较为平坦的。如果残差图显示出了整体或局部字母U或者倒U的形状,那意味着非线性模型会更加适合某些维度。
可以发现右边的残差图在形状上比左边的表现得更加平衡。
残差图还可以在其他情况有所帮助,如果图表显示扇入或者扇出形状(也就是残差在图的一端表现出了比另一端更大的方差,称为异方差),除了加上高阶多项式之外,还有一个解决方案是以对数方式转换目标变量,以便于模型预测log(y),而不是y。
可以使用称为正则化的方法避免过度拟合,从而增加模型的偏差,或者通过惩罚模型参数中的大值来减少方差。正则化为cost function添加了一个额外的元素来惩罚模型中的复杂性。正则化有很多种类型,最常见的是L1和L2正则化。具有L1正则化的线性回归称为Lasso回归,具有L2正则化的线性回归称为Ridge回归。正则化后的cost function如下:
在spark中,可以通过更改LinearRegressionWithSGD.optimizer对象的regParam和updater属性,或使用LassoWithSGD和RidgeRegressionWithSGD类来手动设置Lasso和Ridge回归。
def iterateRidge(iterNums:Array[Int], stepSizes:Array[Double], regParam:Double, train:RDD[LabeledPoint], test:RDD[LabeledPoint]) = {
import org.apache.spark.mllib.regression.RidgeRegressionWithSGD
for(numIter <- iterNums; step <- stepSizes)
{
val alg = new RidgeRegressionWithSGD()
alg.setIntercept(true)
alg.optimizer.setNumIterations(numIter).setRegParam(regParam).setStepSize(step)
val model = alg.run(train)
val rescaledPredicts = train.map(x => (model.predict(x.features), x.label))
val validPredicts = test.map(x => (model.predict(x.features), x.label))
val meanSquared = math.sqrt(rescaledPredicts.map({case(p,l) => math.pow(p-l,2)}).mean())
val meanSquaredValid = math.sqrt(validPredicts.map({case(p,l) => math.pow(p-l,2)}).mean())
println("%d, %5.3f -> %.4f, %.4f".format(numIter, step, meanSquared, meanSquaredValid))
}
}
iterateRidge(Array(200, 400, 1000, 3000, 6000, 10000), Array(1.1), 0.01, trainHPScaled, validHPScaled)
// Our results:
// 200, 1.100 -> 4.2354, 4.0095
// 400, 1.100 -> 4.1355, 3.9790
// 1000, 1.100 -> 4.0425, 3.9661
// 3000, 1.100 -> 3.9842, 3.9695
// 6000, 1.100 -> 3.9674, 3.9728
// 10000, 1.100 -> 3.9607, 3.9745
def iterateLasso(iterNums:Array[Int], stepSizes:Array[Double], regParam:Double, train:RDD[LabeledPoint], test:RDD[LabeledPoint]) = {
import org.apache.spark.mllib.regression.LassoWithSGD
for(numIter <- iterNums; step <- stepSizes)
{
val alg = new LassoWithSGD()
alg.setIntercept(true).optimizer.setNumIterations(numIter).setStepSize(step).setRegParam(regParam)
val model = alg.run(train)
val rescaledPredicts = train.map(x => (model.predict(x.features), x.label))
val validPredicts = test.map(x => (model.predict(x.features), x.label))
val meanSquared = math.sqrt(rescaledPredicts.map({case(p,l) => math.pow(p-l,2)}).mean())
val meanSquaredValid = math.sqrt(validPredicts.map({case(p,l) => math.pow(p-l,2)}).mean())
println("%d, %5.3f -> %.4f, %.4f".format(numIter, step, meanSquared, meanSquaredValid))
println("\tweights: "+model.weights)
}
}
iterateLasso(Array(200, 400, 1000, 3000, 6000, 10000, 15000), Array(1.1), 0.01, trainHPScaled, validHPScaled)
// 200, 1.100 -> 4.1762, 4.0223
// 400, 1.100 -> 4.0632, 3.9964
// 1000, 1.100 -> 3.9496, 3.9987
// 3000, 1.100 -> 3.8636, 4.0362
// 6000, 1.100 -> 3.8239, 4.0705
// 10000, 1.100 -> 3.7985, 4.1014
// 15000, 1.100 -> 3.7806, 4.1304
从结果上看,Ridge给出了比Lasso更低的RMSE。但是不管是Ridge还是Lasso,在迭代次数超过400次时,也发生了过度拟合的现象。
k折交叉验证是模型验证的方法,它包括将数据集划分为大致相等大小的k个子集和训练k个模型,计算所有k个模型的平均误差,最后选择一组参数,给出最小平均误差。