使用SPARK进行特征工程

特征工程

在机器学习领域,有一条尽人皆知的“潜规则”:Garbage in,garbage out。它的意思是说,当我们喂给模型的数据是“垃圾”的时候,模型“吐出”的预测结果也是“垃圾”。垃圾是一句玩笑话,实际上,它指的是不完善的特征工程。特征工程不完善的成因有很多,比如数据质量参差不齐、特征字段区分度不高,还有特征选择不到位、不合理,等等。作为初学者,我们必须要牢记一点:特征工程制约着模型效果,它决定了模型效果的上限,也就是“天花板”。而模型调优,仅仅是在不停地逼近这个“天花板”而已。因此,提升模型效果的第一步,就是要做好特征工程。
特征工程是一个很大的概念,为了便于理解,我将特征工程拆分成了6大部分,如下图所示:
在这里插入图片描述
通常来说,对于原始数据中的字段,我们会把它们分为数值型(Numeric)和非数值型(Categorical)。之所以要这样区分,原因在于字段类型不同,处理方法也不同。在上图中,从左到右,依次是:预处理特征选择归一化离散化Embedding向量计算除此之外,Spark MLlib 还提供了一些用于自然语言处理(NLP,Natural Language Processing)的初级函数,如图中左上角的虚线框所示。我会从每个分类里各挑选一个最具代表性的函数(上图中字体加粗的函数)讲解。

数据集

使用天池DNS卡顿率预测的数据集

预处理

由于绝大多数模型(包括线性回归模型)都不能直接“消费”非数值型数据,因此,咱们的第一步,就是把房屋属性中的非数值字段,转换为数值字段。在特征工程中,对于这类基础的数据转换操作,我们统一把它称为预处理。
我们可以利用 Spark MLlib 提供的 StringIndexer 完成预处理。顾名思义,StringIndexer 的作用是,以数据列为单位,把字段中的字符串转换为数值索引。例如,使用 StringIndexer,我们可以把“车库类型”属性 GarageType 中的字符串转换为数字,如下图所示。
在这里插入图片描述
StringIndexer 的用法比较简单,可以分为三个步骤:
第一步,实例化 StringIndexer 对象;
第二步,通过 setInputCol 和 setOutputCol 来指定输入列和输出列;
第三步,调用 fit 和 transform 函数,完成数据转换。

然后,我们挑选出所有的非数值字段,并使用 StringIndexer 对其进行转换。

from pyspark import SparkConf
from pyspark.ml import Pipeline
from pyspark.ml.feature import StandardScaler, StringIndexer, QuantileDiscretizer, MinMaxScaler
from pyspark.ml.linalg import VectorUDT, Vectors
from pyspark.sql import SparkSession
from pyspark.sql.functions import *
from pyspark.sql.types import *
from pyspark.sql import functions as F
if __name__ == '__main__':
    col=["domain_name","node_name","avg_fbt_time","synack1_ratio","tcp_conntime","icmp_lossrate","icmp_rtt","ratio_499_5xx","inner_network_droprate", "cpu_util","mem_util",
         "io_await_avg","io_await_max","io_util_avg","io_util_max","ng_traf_level"]
    datasetPath ="E:/projects/spark/dataset/training_dataset.csv"
    spark = SparkSession.builder.\
        appName("test"). \
        master("local[*]").\
        getOrCreate()
    samples = spark.read.format('csv').option("sep",",").option('header', 'true').load(datasetPath)
# 所有非数值型字段,也即StringIndexer所需的“输入列”
    input=["domain_name","node_name"]
# StringIndexer所需的“输出列” 
    output=["domain_nameINT","node_nameINT"]
#核心代码:循环遍历所有非数值字段,依次定义StringIndexer,完成字符串到数值索引的转换
    for i in range(len(input)):
        indexer=StringIndexer(inputCol=input[i],outputCol=output[i])
        samples=indexer.fit(samples).transform(samples)
    samples.show(10)

尽管代码看上去很多,但我们只需关注与 StringIndexer 有关的部分即可。我们刚刚介绍了 StringIndexer 用法的三个步骤,咱们不妨把这些步骤和上面的代码对应起来,这样可以更加直观地了解 StringIndexer 的具体用法。
在这里插入图片描述
以domain_name字段为例,我们先初始化一个 StringIndexer 实例。然后,把 domain_name传入给它的 setInputCol 函数。接着,把 domain_nameINT传入给它的 setOutputCol 函数。最后,我们在 StringIndexer 之上,依次调用 fit 和 transform 函数来生成输出列,这两个函数的参数都是待转换的 DataFrame。转换完成之后,你会发现 DF 中多了一个新的数据列,也就是 domain_nameINT 这个字段。而这一列包含的数据内容,就是与 domain_name 数据列对应的数值索引。

特征选择

特征选择顾名思义,就是遴选出关键特征,然后进行建模。实际上,面对数量众多的候选特征,业务经验往往是特征选择的重要出发点之一。与此同时,我们还会使用一些统计方法,去计算候选特征与预测标的之间的关联性,从而以量化的方式,衡量不同特征对于预测标的重要性。统计方法在验证专家经验有效性的同时,还能够与之形成互补,因此,在日常做特征工程的时候,我们往往将两者结合去做特征选择。业务经验因场景而异,无法概述,因此,咱们重点来说一说可以量化的统计方法。
在这里插入图片描述
统计方法的原理并不复杂,本质上都是基于不同的算法(如 Pearson 系数、卡方分布),来计算候选特征与预测标的之间的关联性。
关于特征选择方法的详细介绍可见
Spark MLlib 框架为我们提供了多种特征选择器(Selectors),这些 Selectors 封装了不同的统计方法。接下来,咱们还是以“房价预测”的项目为例,说一说 ChiSqSelector 的用法与注意事项。
既然是量化方法,这就意味着 Spark MLlib 的 Selectors 只能用于数值型字段。要使用 ChiSqSelector 来选择数值型字段,我们需要完成两步走:
第一步,使用 VectorAssembler 创建特征向量;
第二步,基于特征向量,使用 ChiSqSelector 完成特征选择。
VectorAssembler 原本属于特征工程中向量计算的范畴,不过,在 Spark MLlib 框架内,很多特征处理函数的输入参数都是特性向量(Feature Vector),比如现在要讲的 ChiSqSelector。因此,这里我们先要对 VectorAssembler 做一个简单的介绍。

VectorAssembler

VectorAssembler 的作用是,把多个数值列捏合为一个特征向量。以房屋数据的三个数值列“LotFrontage”、“BedroomAbvGr”、“KitchenAbvGr”为例,VectorAssembler 可以把它们捏合为一个新的向量字段,如下图所示。
在这里插入图片描述
VectorAssembler 的用法很简单,初始化 VectorAssembler 实例之后,调用 setInputCols 传入待转换的数值字段列表(如上图中的 3 个字段),使用 setOutputCol 函数来指定待生成的特性向量字段,如上图中的“features”字段。接下来,我们结合代码,来演示 VectorAssembler 的具体用法。

if __name__ == '__main__':
# 所有数值列(包含lable)
    col=["avg_fbt_time","synack1_ratio","tcp_conntime","icmp_lossrate","icmp_rtt","ratio_499_5xx","inner_network_droprate", "cpu_util","mem_util",
         "io_await_avg","io_await_max","io_util_avg","io_util_max","ng_traf_level","buffer_rate"]
# 所有数值列(不包含lable)
    intcol=["avg_fbt_time","synack1_ratio","tcp_conntime","icmp_lossrate","icmp_rtt","ratio_499_5xx","inner_network_droprate", "cpu_util","mem_util",
         "io_await_avg","io_await_max","io_util_avg","io_util_max","ng_traf_level"]
         
    datasetPath ="E:/projects/spark/dataset/training_dataset.csv"
    spark = SparkSession.builder.\
        appName("test"). \
        master("local[*]").\
        getOrCreate()
    samples = spark.read.format('csv').option("sep",",").option('header', 'true').load(datasetPath)
    
    # 将所有数值型字段,转换为整型Int
    for i in col:
        samples=samples.withColumn(i+"int",F.col(i).cast(IntegerType())).drop(i)
    for i in range(len(intcol)):
        intcol[i]=intcol[i]+"int"
    # 向量化
    toVec=VectorAssembler(inputCols=intcol,outputCol="features")
    samples=toVec.transform(samples)

代码内容较多,我们把目光集中到最下面的两行。首先,我们定义并初始化 VectorAssembler 实例,将包含有全部数值字段的数组 intcol传入给 InputCols 函数,并使用 OutputCol 函数指定输出列名为“features”。然后,通过调用 VectorAssembler 的 transform 函数,完成对 engineeringDF 的转换。转换完成之后,engineeringDF 就包含了一个字段名为“features”的数据列,它的数据内容,就是拼接了所有数值特征的特征向量。这里有两点需要注意:
1、InputCols的类型是一个数组,穿字符串会报错。
2、不需要fit()函数,直接transform()即可。
好啦,特征向量准备完毕之后,我们就可以基于它来做特征选择了。

    samples = spark.read.format('csv').option("sep",",").option('header', 'true').load(datasetPath)
    # 将所有数值型字段,转换为整型Int
    for i in col:
        samples=samples.withColumn(i+"int",F.col(i).cast(IntegerType())).drop(i)
    for i in range(len(intcol)):
        intcol[i]=intcol[i]+"int"
    toVec=VectorAssembler(inputCols=intcol,outputCol="features")
    samples=toVec.transform(samples)
    selecter=ChiSqSelector(featuresCol="features",labelCol="buffer_rateint",numTopFeatures=10)
    module=selecter.fit(samples)
    a= module.selectedFeatures()
    print(a)

首先,我们定义并初始化 ChiSqSelector 实例,分别通过 FeaturesCol 和 LabelCol 来指定特征向量和预测标的。ChiSqSelector 所封装的卡方检验,需要将特征与预测标的进行关联,才能量化每一个特征的重要性。接下来,我们需要告诉 ChiSqSelector 要从所有特征值中选出多少个,这里我们传递给 NumTopFeatures 的参数是 10,也就是说,ChiSqSelector 需要帮我们从所有的特征中,挑选出对lable影响最重要的前10个特征。ChiSqSelector 实例创建完成之后,我们通过调用 fit 函数,对DF进行卡方检验,得到卡方检验模型 Model。访问 Model 的 selectedFeatures 函数,即可获得入选特征的索引值,再结合原始的数值字段数组,我们就可以得到入选的原始数据列。

归一化

归一化的作用,是把一组数值,统一映射到同一个值域,而这个值域通常是[0, 1]。当原始数据之间的量纲差异较大时,在模型训练的过程中,梯度下降不稳定、抖动较大,模型不容易收敛,从而导致训练效率较差。相反,当所有特征数据都被约束到同一个值域时,模型训练的效率会得到大幅提升。
Spark MLlib 支持多种多样的归一化函数,如 StandardScaler、MinMaxScaler,等等。尽管这些函数的算法各有不同,但效果都是一样的。我们以 MinMaxScaler 为例,MinMaxScaler 会把所有的数值都映射到[0, 1]这个范围。接下来,我们结合代码,来演示 MinMaxScaler 的具体用法。
与很多特征处理函数(如刚刚讲过的 ChiSqSelector)一样,MinMaxScaler 的输入参数也是特征向量,因此,MinMaxScaler 的用法,也分为两步走:第一步,使用 VectorAssembler 创建特征向量;第二步,基于特征向量,使用 MinMaxScaler 完成归一化。


// 所有类型为Int的数值型字段
// val numericFeatures: Array[String] = numericFields.map(_ + "Int").toArray
 
// 遍历每一个数值型字段
for (field <- numericFeatures) {
 
// 定义并初始化VectorAssembler
val assembler = new VectorAssembler()
.setInputCols(Array(field))
.setOutputCol(s"${field}Vector")
 
// 调用transform把每个字段由Int转换为Vector类型
engineeringData = assembler.transform(engineeringData)
}

在第一步,我们使用 for 循环遍历所有数值型字段,依次初始化 VectorAssembler 实例,把字段由 Int 类型转为 Vector 向量类型。接下来,在第二步,我们就可以把所有向量传递给 MinMaxScaler 去做归一化了。可以看到,MinMaxScaler 的用法,与 StringIndexer 的用法很相似。


import org.apache.spark.ml.feature.MinMaxScaler
 
// 锁定所有Vector数据列
val vectorFields: Array[String] = numericFeatures.map(_ + "Vector").toArray
 
// 归一化后的数据列
val scaledFields: Array[String] = vectorFields.map(_ + "Scaled").toArray
 
// 循环遍历所有Vector数据列
for (vector <- vectorFields) {
 
// 定义并初始化MinMaxScaler
val minMaxScaler = new MinMaxScaler()
.setInputCol(vector)
.setOutputCol(s"${vector}Scaled")
// 使用MinMaxScaler,完成Vector数据列的归一化
engineeringData = minMaxScaler.fit(engineeringData).transform(engineeringData)
}

首先,我们创建一个 MinMaxScaler 实例,然后分别把原始 Vector 数据列和归一化之后的数据列,传递给函数 setInputCol 和 setOutputCol。接下来,依次调用 fit 与 transform 函数,完成对目标字段的归一化。这段代码执行完毕之后,engineeringData(DataFrame)就包含了多个后缀为“Scaled”的数据列,这些数据列的内容,就是对应原始字段的归一化数据,如下所示。
在这里插入图片描述

离散化

离散化:Bucketizer与归一化一样,离散化也是用来处理数值型字段的。离散化可以把原本连续的数值打散,从而降低原始数据的多样性(Cardinality)。举例来说,“BedroomAbvGr”字段的含义是居室数量,在 train.csv 这份数据样本中,“BedroomAbvGr”包含从 1 到 8 的连续整数。现在,我们根据居室数量,把房屋粗略地划分为小户型、中户型和大户型。

在这里插入图片描述
不难发现,“BedroomAbvGr”离散化之后,数据多样性由原来的 8 降低为现在的 3。那么问题来了,原始的连续数据好好的,为什么要对它做离散化呢?离散化的动机,主要在于提升特征数据的区分度与内聚性,从而与预测标的产生更强的关联。就拿“BedroomAbvGr”来说,我们认为一居室和两居室对于房价的影响差别不大,同样,三居室和四居室之间对于房价的影响,也是微乎其微。但是,小户型与中户型之间,以及中户型与大户型之间,房价往往会出现跃迁的现象。换句话说,相比居室数量,户型的差异对于房价的影响更大、区分度更高。因此,把“BedroomAbvGr”做离散化处理,目的在于提升它与预测标的之间的关联性。
与其他环节一样,Spark MLlib 提供了多个离散化函数,比如 Binarizer、Bucketizer 和 QuantileDiscretizer。我们不妨以 Bucketizer 为代表,结合居室数量“BedroomAbvGr”这个字段,来演示离散化的具体用法。老规矩,还是先上代码为敬。


// 原始字段
val fieldBedroom: String = "BedroomAbvGrInt"
// 包含离散化数据的目标字段
val fieldBedroomDiscrete: String = "BedroomDiscrete"
// 指定离散区间,分别是[负无穷, 2][3, 4][5, 正无穷]
val splits: Array[Double] = Array(Double.NegativeInfinity, 3, 5, Double.PositiveInfinity)
 
import org.apache.spark.ml.feature.Bucketizer
 
// 定义并初始化Bucketizer
val bucketizer = new Bucketizer()
// 指定原始列
.setInputCol(fieldBedroom)
// 指定目标列
.setOutputCol(fieldBedroomDiscrete)
// 指定离散区间
.setSplits(splits)
 
// 调用transform完成离散化转换
engineeringData = bucketizer.transform(engineeringData)

不难发现,Spark MLlib 提供的特征处理函数,在用法上大同小异。首先,我们创建 Bucketizer 实例,然后将数值型字段 BedroomAbvGrInt 作为参数传入 setInputCol,同时使用 setOutputCol 来指定用于保存离散数据的新字段 BedroomDiscrete。离散化的过程是把连续值打散为离散值,但具体的离散区间如何划分,还需要我们通过在 setSplits 里指定。离散区间由浮点型数组 splits 提供,从负无穷到正无穷划分出了[负无穷, 2]、[3, 4]和[5, 正无穷]这三个区间。最终,我们调用 Bucketizer 的 transform 函数,对 engineeringData 做离散化。离散化前后的数据对比,如下图所示。
在这里插入图片描述

Embedding

Embedding 是一个非常大的话题,随着机器学习与人工智能的发展,Embedding 的方法也是日新月异、层出不穷。从最基本的热独编码到 PCA 降维,从 Word2Vec 到 Item2Vec,从矩阵分解到基于深度学习的协同过滤,可谓百花齐放、百家争鸣。那么问题来了,什么是 Embedding 呢?Embedding 的过程,就是把数据集合映射到向量空间,进而把数据进行向量化的过程。
关于Embedding的详细介绍
在预处理环节,我们使用 StringIndexer,把字符串转换为连续的整数,然后让模型去消费这些整数。在理论上,这么做没有任何问题。但从模型的效果出发,整数的表达方式并不合理。我们知道,连续整数之间,是存在比较关系的,比如 1 < 3,6 > 5,等等。但是原始的字符串之间,比如,“Attchd”与“Detchd”并不存在大小关系,如果强行用 0 表示“Attchd”、用 1 表示“Detchd”,逻辑上就会出现“Attchd”<“Detchd”的悖论。因此,预处理环节的 StringIndexer,仅仅是把字符串转换为数字,转换得到的数值是不能直接喂给模型做训练。我们需要把这些数字进一步向量化,才能交给模型去消费。那么问题来了,对于 StringIndexer 输出的数值,我们该怎么对他们进行向量化呢?这就要用到 Embedding 了。
咱们不妨从最简单的热独编码(One Hot Encoding)开始,去认识 Embedding 并掌握它的基本用法。我们先来说说,热独编码,是怎么一回事。相比照本宣科说概念,咱们不妨以 GarageType 为例,从示例入手,你反而更容易心领神会。
在这里插入图片描述
首先,通过 StringIndexer,我们把 GarageType 的 6 个取值分别映射为 0 到 5 的六个数值。接下来,使用热独编码,我们把每一个数值都转化为一个向量。向量的维度为 6,与原始字段(GarageType)的多样性(Cardinality)保持一致。换句话说,热独编码的向量维度,就是原始字段的取值个数。仔细观察上图的六个向量,只有一个维度取值为 1,其他维度全部为 0。取值为 1 的维度与 StringIndexer 输出的索引相一致。举例来说,字符串“Attchd”被 StringIndexer 映射为 0,对应的热独向量是[1, 0, 0, 0, 0, 0]。向量中索引为 0 的维度取值为 1,其他维度全部取 0。不难发现,热独编码是一种简单直接的 Embedding 方法,甚至可以说是“简单粗暴”。不过,在日常的机器学习开发中,“简单粗暴”的热独编码却颇受欢迎。接下来,我们还是从“房价预测”的项目出发,说一说热独编码的具体用法。在预处理环节,我们已经用 StringIndexer 把非数值字段全部转换为索引字段,接下来,我们再用 OneHotEncoder,把索引字段进一步转换为向量字段。


import org.apache.spark.ml.feature.OneHotEncoder
 
// 非数值字段对应的目标索引字段,也即StringIndexer所需的“输出列”
// val indexFields: Array[String] = categoricalFields.map(_ + "Index").toArray
 
// 热独编码的目标字段,也即OneHotEncoder所需的“输出列”
val oheFields: Array[String] = categoricalFields.map(_ + "OHE").toArray
 
// 循环遍历所有索引字段,对其进行热独编码
for ((indexField, oheField) <- indexFields.zip(oheFields)) {
val oheEncoder = new OneHotEncoder()
.setInputCol(indexField)
.setOutputCol(oheField)
engineeringData= oheEncoder.transform(engineeringData)
}

可以看到,我们循环遍历所有非数值特征,依次创建 OneHotEncoder 实例。在实例初始化的过程中,我们把索引字段传入给 setInputCol 函数,把热独编码目标字段传递给 setOutputCol 函数。最终通过调用 OneHotEncoder 的 transform,在 engineeringData 之上完成转换。

向量计算

向量计算,作为特征工程的最后一个环节,主要用于构建训练样本中的特征向量(Feature Vectors)。在 Spark MLlib 框架下,训练样本由两部分构成,第一部分是预测标的(Label),在“房价预测”的项目中,Label 是房价。而第二部分,就是特征向量,在形式上,特征向量可以看作是元素类型为 Double 的数组。根据前面的特征工程流程图,我们不难发现,特征向量的构成来源多种多样,比如原始的数值字段、归一化或是离散化之后的数值字段、以及向量化之后的特征字段,等等。Spark MLlib 在向量计算方面提供了丰富的支持,比如前面介绍过的、用于集成特征向量的 VectorAssembler,用于对向量做剪裁的 VectorSlicer,以元素为单位做乘法的 ElementwiseProduct,等等。灵活地运用这些函数,我们可以随意地组装特征向量,从而构建模型所需的训练样本。在前面的几个环节中(预处理、特征选择、归一化、离散化、Embedding),我们尝试对数值和非数值类型特征做各式各样的转换,目的在于探索可能对预测标的影响更大的潜在因素。接下来,我们使用 VectorAssembler 将这些潜在因素全部拼接在一起、构建特征向量,从而为后续的模型训练准备好训练样本。


import org.apache.spark.ml.feature.VectorAssembler
 
/**
入选的数值特征:selectedFeatures
归一化的数值特征:scaledFields
离散化的数值特征:fieldBedroomDiscrete
热独编码的非数值特征:oheFields
*/
 
val assembler = new VectorAssembler()
.setInputCols(selectedFeatures ++ scaledFields ++ fieldBedroomDiscrete ++ oheFields)
.setOutputCol("features")
 
engineeringData = assembler.transform(engineeringData)

转换完成之后,engineeringData 这个 DataFrame 就包含了一列名为“features”的新字段,这个字段的内容,就是每条训练样本的特征向量。接下来,我们就可以像上一讲那样,通过 setFeaturesCol 和 setLabelCol 来指定特征向量与预测标的,定义出线性回归模型。


// 定义线性回归模型
val lr = new LinearRegression()
.setFeaturesCol("features")
.setLabelCol("SalePriceInt")
.setMaxIter(100)
 
// 训练模型
val lrModel = lr.fit(engineeringData)
 
// 获取训练状态
val trainingSummary = lrModel.summary
// 获取训练集之上的预测误差
println(s"Root Mean Squared Error (RMSE) on train data: ${trainingSummary.rootMeanSquaredError}")

好啦,到此为止,我们打通了特征工程所有关卡,恭喜你!尽管不少关卡还有待我们进一步去深入探索,但这并不影响我们从整体上把握特征工程,构建结构化的知识体系。

效果对比

特征工程任一环节的输出,都可以用来构建特征向量,用于模型训练。在介绍特征工程的部分,我们花了大量篇幅介绍不同环节的作用与用法。你可能会好奇:“这些不同环节的特征处理,真的会对模型效果有帮助吗?毕竟,折腾了半天,我们还是要看模型效果的”。没错,特征工程的最终目的,是调优模型效果。接下来,通过将不同环节输出的训练样本喂给模型,我们来对比不同特征处理方法对应的模型效果。
在这里插入图片描述
代码地址

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值