数据挖掘算法中有很大一部分都是数据预处理工作,毕竟现有模型都是比较成熟的,只需要学会调用就好,如何把原始数据转化为算法模型适用的数据结构也是很重要的一步。spark ML中提供了对特征的提取(Extracting),转换(transforming)和选择(selecting)工具。
“词频-逆向文件频率”(TF-IDF)是一种在文本挖掘中广泛使用的特征向量化方法,它可以体现一个文档中词语在语料库中的重要程度。
词频TF(t,d)是词语t在文档d中出现的次数。文件频率DF(t,D)是包含词语的文档的个数。如果我们只使用词频来衡量重要性,很容易过度强调在文档中经常出现,却没有太多实际信息的词语,比如“a”,“the”以及“of”。如果一个词语经常出现在语料库中,意味着它并不能很好的对文档进行区分。TF-IDF就是在数值化文档信息,衡量词语能提供多少信息以区分文档。定义如下:
此处 |D| 是语料库中总的文档数。公式中使用log函数,当词出现在所有文档中时,它的IDF值变为0。加1是为了避免分母为0的情况。TF-IDF 度量值表示如下:
在Spark ML库中,TF-IDF被分成两部分:TF(HashingTF) 和 IDF
Spark.mllib 中实现词频率统计使用特征hash的方式,原始特征通过hash函数,映射到一个索引值。后面只需要统计这些索引值的频率,就可以知道对应词的频率。这种方式避免设计一个全局1对1的词到索引的映射,这个映射在映射大量语料库时需要花费更长的时间。但需要注意,通过hash的方式可能会映射到同一个值的情况,即不同的原始特征通过Hash映射后是同一个值。为了降低这种情况出现的概率,我们只能对特征向量升维。i.e., 提高hash表的桶数,默认特征维度是 2^20 = 1,048,576.
在下面的代码段中,我们以一组句子开始。首先使用分解器Tokenizer把句子划分为单个词语。对每一个句子(词袋),我们使用HashingTF将句子转换为特征向量,最后使用IDF重新调整特征向量。这种转换通常可以提高使用文本特征的性能。
在下面的代码中,我们以一组句子为例,使用Tokenizer将每一条句子分解为单词,对每一条句子(词袋),我们使用HashingTF 将其转化为特征向量,最后使用IDF 重新调整特征向量。
import org.apache.spark.ml.feature.{HashingTF, IDF, Tokenizer}
// 创建一个简单的DataFrame,每一个句子代表一个文档
val sentenceData = spark.createDataFrame(Seq(
(0.0, "Hi I heard about Spark"),
(0.0, "I wish Java could use case classes"),
(1.0, "Logistic regression models are neat")
)).toDF("label", "sentence")
// 在得到文档集合后,即可用tokenizer对句子进行分词。
val tokenizer = new Tokenizer().setInputCol("sentence").setOutputCol("words")
val wordsData = tokenizer.transform(sentenceData)
wordsData.show(false) // 如果不设置为false的话,将会省略掉20个以后的字符
/*
+-----+-----------------------------------+------------------------------------------+
|label|sentence |words |
+-----+-----------------------------------+------------------------------------------+
|0.0 |Hi I heard about Spark |[hi, i, heard, about, spark] |
|0.0 |I wish Java could use case classes |[i, wish, java, could, use, case, classes]|
|1.0 |Logistic regression models are neat|[logistic, regression, models, are, neat] |
+-----+-----------------------------------+------------------------------------------+
*/
// 使用HashingTF的transform()方法把句子哈希成特征向量,这里设置哈希表的桶数为2000。
val hashingTF = new HashingTF()
.setInputCol("words").setOutputCol("rawFeatures").setNumFeatures(20)
val featurizedData = hashingTF.transform(wordsData)
featurizedData.show(false)
/*
可以看到,分词序列被变换成一个稀疏特征向量,其中每个单词都被散列成了一个不同的索引值,特征向量在某一维度上的值即该词汇在文档中出现的次数
+-----------------------------------------+
|rawFeatures |
+-----------------------------------------+
|(20,[5,6,9],[2.0,1.0,2.0]) |
|(20,[3,5,12,14,18],[2.0,2.0,1.0,1.0,1.0])|
|(20,[5,12,14,18],[1.0,2.0,1.0,1.0]) |
+-----------------------------------------+
*/
// 最后,使用IDF来对单纯的词频特征向量进行修正,使其更能体现不同词汇对文本的区别能力,IDF是一个Estimator,调用fit()方法并将词频向量传入,即产生一个IDFModel。
val idf = new IDF().setInputCol("rawFeatures").setOutputCol("features")
val idfModel = idf.fit(featurizedData)
// 很显然,IDFModel是一个Transformer,调用它的transform()方法,即可得到每一个单词对应的TF-IDF度量值。
val rescaledData = idfModel.transform(featurizedData)
rescaledData.select("label", "features").take(3).foreach(println)
/*
可以看到,特征向量已经被其在语料库中出现的总次数进行了修正,通过TF-IDF得到的特征向量,在接下来可以被应用到相关的机器学习方法中
[0.0,(20,[5,6,9],[0.0,0.6931471805599453,1.3862943611198906])]
[0.0,(20,[3,5,12,14,18],[1.3862943611198906,0.0,0.28768207245178085,0.28768207245178085,0.28768207245178085])]
[1.0,(20,[5,12,14,18],[0.0,0.5753641449035617,0.28768207245178085,0.28768207245178085])]
*/
值得注意的是,用于特征转换的转换器和其他的机器学习算法一样,也属于ML Pipeline模型的一部分,可以用来构成机器学习流水线,以StringIndexer为例,其存储着进行标签数值化过程的相关 超参数,是一个Estimator,对其调用fit(..)方法即可生成相应的模型StringIndexerModel类,很显然,它存储了用于DataFrame进行相关处理的 参数,是一个Transformer(其他转换器也是同一原理)
根据源码中的解释 :
A tokenizer that converts the input string to lowercase and then splits it by white spaces.
tokenizer 主要有两个功能,1. 将词语转为小写字母 2. 按空格划分字符串
注: 如果想使用其他规则划分字符串,可以使用 RegexTokenizer
import org.apache.spark.ml.feature.{RegexTokenizer, Tokenizer}
import org.apache.spark.sql.functions._
val sentenceDataFrame = spark.createDataFrame(Seq(
(0, "Hi I heard about Spark"),
(1, "I wish Java could use case classes"),
(2, "Logistic,regression,models,are,neat")
)).toDF("id", "sentence")
val tokenizer = new Tokenizer().setInputCol("sentence").setOutputCol("words")
val regexTokenizer = new RegexTokenizer()
.setInputCol("sentence")
.setOutputCol("words")
.setPattern("\\W") // alternatively .setPattern("\\w+").setGaps(false)
// 自定义一个函数
val countTokens = udf { (words: Seq[String]) => words.length }
val tokenized = tokenizer.transform(sentenceDataFrame)
val regexTokenized = regexTokenizer.transform(sentenceDataFrame)
// 增加一个自定义列
tokenized.select("sentence", "words")
.withColumn("tokens", countTokens(col("words"))).show(false)
/*
+-----------------------------------+------------------------------------------+------+
|sentence |words |tokens|
+-----------------------------------+------------------------------------------+------+
|Hi I heard about Spark |[hi, i, heard, about, spark] |5 |
|I wish Java could use case classes |[i, wish, java, could, use, case, classes]|7 |
|Logistic,regression,models,are,neat|[logistic,regression,models,are,neat] |1 |
+-----------------------------------+------------------------------------------+------+
*/
regexTokenized.select("sentence", "words")
.withColumn("tokens", countTokens(col("words"))).show(false)
/*
+-----------------------------------+------------------------------------------+------+
|sentence |words |tokens|
+-----------------------------------+------------------------------------------+------+
|Hi I heard about Spark |[hi, i, heard, about, spark] |5 |
|I wish Java could use case classes |[i, wish, java, could, use, case, classes]|7 |
|Logistic,regression,models,are,neat|[logistic, regression, models, are, neat] |5 |
+-----------------------------------+------------------------------------------+------+
*/
很明显的可以看到二者的区别,tokenized是将非空格分割的字符串当做 一个整体。
连续特征根据阈值二值化,大于阈值的为1.0,小于等于阈值的为0.0。二值化是机器学习中很常见的思路,可以将连续型数据转化为离散型。
import org.apache.spark.ml.feature.Binarizer
val data = Array((0, 0.1), (1, 0.8), (2, 0.2))
val dataFrame = spark.createDataFrame(data).toDF("id", "feature")
val binarizer: Binarizer = new Binarizer()
.setInputCol("feature")
.setOutputCol("binarized_feature")
.setThreshold(0.5)
val binarizedDataFrame = binarizer.transform(dataFrame)
println(s"Binarizer output with Threshold = ${binarizer.getThreshold}")
binarizedDataFrame.show()
/*
+---+-------+-----------------+
| id|feature|binarized_feature|
+---+-------+-----------------+
| 0| 0.1| 0.0|
| 1| 0.8| 1.0|
| 2| 0.2| 0.0|
+---+-------+-----------------+
*/
StringIndexer转换器可以把一列类别型的特征(或标签)进行编码,使其数值化,索引的范围从0开始,该过程可以使得相应的特征索引化,使得某些无法接受类别型特征的算法可以使用,并提高诸如决策树等机器学习算法的效率。
索引构建的顺序为标签的频率,优先编码频率较大的标签,所以出现频率最高的标签为0号。
如果输入的是数值型的,我们会把它转化成字符型,然后再对其进行编码。直接上代码。
首先创建一个简单的DataFrame,它只包含一个id列和一个标签列category:
import org.apache.spark.ml.feature.StringIndexer
val df = spark.createDataFrame(
Seq((0, "a"), (1, "b"), (2, "c"), (3, "a"), (4, "a"), (5, "c"))
).toDF("id", "category")
df.show(false)
/*
+---+--------+
|id |category|
+---+--------+
|0 |a |
|1 |b |
|2 |c |
|3 |a |
|4 |a |
|5 |c |
+---+--------+
*/
随后,我们创建一个StringIndexer对象,设定输入输出列名,其余参数采用默认值,并对这个DataFrame进行训练,产生StringIndexerModel对象, 最后利用该对象对DataFrame进行转换操作。可以看到,StringIndexerModel依次按照出现频率的高低,把字符标签进行了排序,即出现最多的“a”被编号成0,“c”为1,出现最少的“b”为2。
val indexer = new StringIndexer()
.setInputCol("category")
.setOutputCol("categoryIndex")
val indexed = indexer.fit(df)
val model = indexed.transform(df)
model.show()
/*
+---+--------+-------------+
|id |category|categoryIndex|
+---+--------+-------------+
|0 |a |0.0 |
|1 |b |2.0 |
|2 |c |1.0 |
|3 |a |0.0 |
|4 |a |0.0 |
|5 |c |1.0 |
+---+--------+-------------+
*/
考虑这样一种情况,我们使用已有的数据构建了一个StringIndexerModel,然后再构建一个新的DataFrame,这个DataFrame中有着模型内未曾出现的标签“d”,用已有的模型去转换这一DataFrame会有什么效果?
实际上,如果直接转换的话,Spark会抛出异常,报出“Unseen label: d”的错误。
为了处理这种情况,在模型训练后,可以通过设置setHandleInvalid(“skip”)来忽略掉那些未出现的标签,这样,带有未出现标签的行将直接被过滤掉,
测试一下,我们创建一个df2,包含df未出现的字符d,用df训练的模型来转换df2。
val df2 = spark.createDataFrame(
Seq((0, "a"), (1, "b"), (2, "c"), (3, "a"), (4, "a"), (5, "c"), (6, "d"))
).toDF("id", "category")
indexed.transform(df2).show(false)
报错:org.apache.spark.SparkException: Unseen label: d.
indexed.setHandleInvalid("skip").transform(df2).show(false)
/*
+---+--------+-------------+
|id |category|categoryIndex|
+---+--------+-------------+
|0 |a |0.0 |
|1 |b |2.0 |
|2 |c |1.0 |
|3 |a |0.0 |
|4 |a |0.0 |
|5 |c |1.0 |
+---+--------+-------------+
*/
与StringIndexer相对应,IndexToString的作用是把标签索引的一列重新映射回原有的字符型标签,一般都是和StringIndexer配合使用,先用StringIndexer将原有标签转化成标签索引,进行模型训练,然后在预测标签的时候再把标签索引转化成原有的字符标签。
我们还是先来创建个DataFrame
val df = sqlContext.createDataFrame(Seq(
(0, "a"),
(1, "b"),
(2, "c"),
(3, "a"),
(4, "a"),
(5, "c")
)).toDF("id", "category")
然后使用StringIndexer标签索引化。
val indexer = new StringIndexer()
.setInputCol("category")
.setOutputCol("categoryIndex")
.fit(df)
val indexed = indexer.transform(df)
/*
+---+--------+-------------+
|id |category|categoryIndex|
+---+--------+-------------+
|0 |a |0.0 |
|1 |b |2.0 |
|2 |c |1.0 |
|3 |a |0.0 |
|4 |a |0.0 |
|5 |c |1.0 |
+---+--------+-------------+
*/
紧接着,使用IndexToString还原标签索引
val converter = new IndexToString()
.setInputCol("categoryIndex")
.setOutputCol("originalCategory")
val converted = converter.transform(indexed)
/*
+---+--------+-------------+----------------+
|id |category|categoryIndex|originalCategory|
+---+--------+-------------+----------------+
|0 |a |0.0 |a |
|1 |b |2.0 |b |
|2 |c |1.0 |c |
|3 |a |0.0 |a |
|4 |a |0.0 |a |
|5 |c |1.0 |c |
+---+--------+-------------+----------------+
*/
独热编码是指把一列标签索引映射成一列二进制数组,且最多的时候只有一位有效。这种编码适合一些期望类别特征为连续特征的算法(回归),个人感觉独热编码比较适用,其原理这里就不说了,给大家一个连接:
http://blog.csdn.net/bitcarmanlee/article/details/51472816
与sklearn 不同之处在于sparkml中使用SparseVector来表示。
import org.apache.spark.ml.feature.{OneHotEncoder, StringIndexer}
val df = sqlContext.createDataFrame(Seq(
(0, "a"),
(1, "b"),
(2, "c"),
(3, "a"),
(4, "a"),
(5, "c")
)).toDF("id", "category")
val indexer = new StringIndexer()
.setInputCol("category")
.setOutputCol("categoryIndex")
.fit(df)
val indexed = indexer.transform(df)
val encoder = new OneHotEncoder()
.setInputCol("categoryIndex")
.setOutputCol("categoryVec")
val encoded = encoder.transform(indexed)
encoded.select("id", "categoryVec").show()
/*
+---+--------+-------------+-------------+
| id|category|categoryIndex| categoryVec|
+---+--------+-------------+-------------+
| 0| a| 0.0|(2,[0],[1.0])|
| 1| b| 2.0| (2,[],[])|
| 2| c| 1.0|(2,[1],[1.0])|
| 3| a| 0.0|(2,[0],[1.0])|
| 4| a| 0.0|(2,[0],[1.0])|
| 5| c| 1.0|(2,[1],[1.0])|
+---+--------+-------------+-------------+
*/
将categoryVec转换成编码模式:a -> 10 , c ->01 ,至于b为空,是应为spark默认不包含最后一个类别,OneHotEncoder.setDropLast(false)可以设置是否包含最后一个类别。
VectorIndexer解决向量数据集中的类别特征索引。它可以自动识别哪些特征是类别型(离散)的,并且将原始值转换为类别索引。它的处理流程如下:
1. 获得一个向量类型的输入以及maxCategories参数。
2. 基于不同特征值的数量来识别哪些特征需要被类别化,不同特征值的数量小于maxCategories,则这个特征需要被类别化(离散化),否则视为连续值,不做改变。
3.对于每一个类别特征计算0-based(从0开始)类别索引。
索引后的类别特征可以帮助决策树等算法恰当的处理类别型特征,并得到较好结果。
我们读入一个数据集,然后使用VectorIndexer来决定哪些特征需要被作为类别特征,将类别特征转换为他们的索引。
import org.apache.spark.ml.feature.VectorIndexer
val data = Seq(Vectors.dense(-1.0, 1.0, 1.0),Vectors.dense(-1.0, 3.0, 1.0), Vectors.dense(0.0, 5.0, 1.0))
val df = sqlContext.createDataFrame(data.map(Tuple1.apply)).toDF("features")
val indexer = new VectorIndexer()
.setInputCol("features")
.setOutputCol("indexed")
.setMaxCategories(2)
val indexerModel = indexer.fit(df)
val indexedData = indexerModel.transform(df)
indexedData.show()
/*
+--------------+-------------+
| features| indexed|
+--------------+-------------+
|[-1.0,1.0,1.0]|[1.0,1.0,0.0]|
|[-1.0,3.0,1.0]|[1.0,3.0,0.0]|
| [0.0,5.0,1.0]|[0.0,5.0,0.0]|
+--------------+-------------+
*/
从上例可以看到,我们设置maxCategories为2,即只有种类小于2的特征才被认为是类别型特征,否则被认为是连续型特征。其中类别型特征将被进行编号索引,为了索引的稳定性,规定如果这个特征值为0,则一定会被编号成0,这样可以保证向量的稀疏度。于是,我们可以看到第0类和第2类的特征由于种类数不超过2,被划分成类别型特征,并进行了索引,且为0的特征值也被编号成了0号。
Normalizer是将数据集的每一行数据归一化的转换器,一行数据看做一个向量,它带一个参数P(默认值是2),归一化就是向量中的每个元素除以向量的范数(P规定使用哪一个范数)。
val data = Seq(Vectors.dense(-1.0, 1.0, 1.0),Vectors.dense(-1.0, 3.0, 1.0), Vectors.dense(0.0, 5.0, 1.0))
val df = sqlContext.createDataFrame(data.map(Tuple1.apply)).toDF("features")
val normalizer = new Normalizer()
.setInputCol("features")
.setOutputCol("normFeatures")
.setP(2.0)
val l1NormData = normalizer.transform(dataFrame)
l1NormData.show()
/*
+--------------+-------------------------------------------------------------+
|features |normFeatures |
+--------------+-------------------------------------------------------------+
|[-1.0,1.0,1.0]|[-0.5773502691896258,0.5773502691896258,0.5773502691896258] |
|[-1.0,3.0,1.0]|[-0.30151134457776363,0.9045340337332909,0.30151134457776363]|
|[0.0,5.0,1.0] |[0.0,0.9805806756909202,0.19611613513818404] |
+--------------+-------------------------------------------------------------+
*/
我们设置的P为2,则使用L2范数来归一化向量,L2范数是指向量各元素的平方和然后求平方根,范数的相关资料可以自行百度,针对[-1,1,1],他的L2范数为 3√ , 所以归一化后的向量为 [−13√,13√,13√]
val lInfNormData = normalizer.transform(dataFrame, normalizer.p -> Double.PositiveInfinity)
lInfNormData.show(false)
/*
+--------------+--------------------------------------------+
|features |normFeatures |
+--------------+--------------------------------------------+
|[-1.0,1.0,1.0]|[-1.0,1.0,1.0] |
|[-1.0,3.0,1.0]|[-0.3333333333333333,1.0,0.3333333333333333]|
|[0.0,5.0,1.0] |[0.0,1.0,0.2] |
+--------------+--------------------------------------------+
*/
无穷范数就是取向量中的最大值,所以使用无穷范数归一化后的向量就是原向量中元素除以向量中的最大值。
VectorAssembler是非常实用的一个转换器,它的作用很简单,就是把DataFrame中的若干列合并为一个向量列,可类似于Excel中的合并单元格。有了VectorAssembler之后,除了使用反射机制创建DataFrame之外,我们又有了一种新思路,即通过普通方式创建DataFrame,再使用VectorAssembler创造自己所需要的数据格式。
import org.apache.spark.ml.feature.VectorAssembler
import org.apache.spark.mllib.linalg.Vectors
val dataset = sqlcontext.createDataFrame(
Seq((0, 18, 1.0, Vectors.dense(0.0, 10.0, 0.5), 1.0))
).toDF("id", "hour", "mobile", "userFeatures", "clicked")
val assembler = new VectorAssembler()
.setInputCols(Array("hour", "mobile", "userFeatures"))
.setOutputCol("features")
val output = assembler.transform(dataset)
output.show(false)
/*
+---+----+------+--------------+-------+-----------------------+
|id |hour|mobile|userFeatures |clicked|features |
+---+----+------+--------------+-------+-----------------------+
|0 |18 |1.0 |[0.0,10.0,0.5]|1.0 |[18.0,1.0,0.0,10.0,0.5]|
+---+----+------+--------------+-------+-----------------------+
*/
VectorSlicer是一个切片工具,和VectorAssembler相对应,它的作用就是将一个向量切片,选择向量的子集。它提供了两种切片方式。
import org.apache.spark.ml.attribute.{Attribute, AttributeGroup, NumericAttribute}
import org.apache.spark.ml.feature.VectorSlicer
import org.apache.spark.mllib.linalg.Vectors
import org.apache.spark.sql.Row
import org.apache.spark.sql.types.StructType
val data = Array(Row(Vectors.dense(-2.0, 2.3, 0.0)))
val defaultAttr = NumericAttribute.defaultAttr
val attrs = Array("f1", "f2", "f3").map(defaultAttr.withName)
val attrGroup = new AttributeGroup("userFeatures", attrs.asInstanceOf[Array[Attribute]])
val dataRDD = sc.parallelize(data)
val dataset = sqlContext.createDataFrame(dataRDD, StructType(Array(attrGroup.toStructField())))
val slicer = new VectorSlicer().setInputCol("userFeatures").setOutputCol("features")
slicer.setIndices(Array(1)).setNames(Array("f3"))
// or slicer.setIndices(Array(1, 2)), or slicer.setNames(Array("f2", "f3"))
val output = slicer.transform(dataset)
ouput.show(false)
/*
+--------------+---------+
|userFeatures |features |
+--------------+---------+
|[-2.0,2.3,0.0]|[2.3,0.0]|
+--------------+---------+
*/