You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

230 lines
17 KiB
Markdown

This file contains invisible Unicode characters!

This file contains invisible Unicode characters that may be processed differently from what appears below. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to reveal hidden characters.

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

# 25 | 特征工程(下):有哪些常用的特征处理函数?
你好,我是吴磊。
在上一讲我们提到典型的特征工程包含如下几个环节即预处理、特征选择、归一化、离散化、Embedding和向量计算如下图所示。
![图片](https://static001.geekbang.org/resource/image/fb/8a/fb2e1de527829c503514731396edb68a.jpg?wh=1920x912 "特征工程一览 & Spark MLlib特征处理函数分类")
在上一讲我们着重讲解了其中的前3个环节也就是预处理、特征选择和归一化。按照之前的课程安排今天这一讲咱们继续来说说剩下的离散化、Embedding与向量计算。
特征工程是机器学习的重中之重只要你耐心学下去必然会不虚此行。这一讲的最后我还会对应用了6种不同特征工程的模型性能加以对比帮你深入理解特征工程中不同环节的作用与效果。
## 特征工程
![图片](https://static001.geekbang.org/resource/image/90/fa/901883f5abd7fbc9def60905025faffa.jpg?wh=1920x885 "特征工程进度打卡")
在上一讲,我们打卡到了“第三关”:归一化。因此,接下来,我们先从“第四关”:离散化说起。
### 离散化Bucketizer
与归一化一样离散化也是用来处理数值型字段的。离散化可以把原本连续的数值打散从而降低原始数据的多样性Cardinality。举例来说“BedroomAbvGr”字段的含义是居室数量在train.csv这份数据样本中“BedroomAbvGr”包含从1到8的连续整数。
现在,我们根据居室数量,把房屋粗略地划分为小户型、中户型和大户型。
![图片](https://static001.geekbang.org/resource/image/e8/87/e85c3fabb51693c35dfa444e9bcf6687.jpg?wh=1920x824 "BedroomAbvGr离散化")
不难发现“BedroomAbvGr”离散化之后数据多样性由原来的8降低为现在的3。那么问题来了原始的连续数据好好的为什么要对它做离散化呢**离散化的动机,主要在于提升特征数据的区分度与内聚性,从而与预测标的产生更强的关联**。
就拿“BedroomAbvGr”来说我们认为一居室和两居室对于房价的影响差别不大同样三居室和四居室之间对于房价的影响也是微乎其微。
但是小户型与中户型之间以及中户型与大户型之间房价往往会出现跃迁的现象。换句话说相比居室数量户型的差异对于房价的影响更大、区分度更高。因此把“BedroomAbvGr”做离散化处理目的在于提升它与预测标的之间的关联性。
那么在Spark MLlib的框架下离散化具体该怎么做呢与其他环节一样Spark MLlib提供了多个离散化函数比如Binarizer、Bucketizer和QuantileDiscretizer。我们不妨以Bucketizer为代表结合居室数量“BedroomAbvGr”这个字段来演示离散化的具体用法。老规矩还是先上代码为敬。
```scala
// 原始字段
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做离散化。
离散化前后的数据对比,如下图所示。
![图片](https://static001.geekbang.org/resource/image/ae/3b/ae1fffddda872ed8a7byyf24f098a73b.jpg?wh=1920x967 "离散化前后数据对比")
好啦到此为止我们以Bucketizer为代表学习了Spark MLlib框架中数据离散化的用法轻松打通了特征工程的第四关。
![图片](https://static001.geekbang.org/resource/image/20/75/20766bd849bb45aa55b3dd3db6f75175.jpg?wh=1920x907 "打卡特征工程第四关:离散化")
### Embedding
实际上Embedding是一个非常大的话题随着机器学习与人工智能的发展Embedding的方法也是日新月异、层出不穷。从最基本的热独编码到PCA降维从Word2Vec到Item2Vec从矩阵分解到基于深度学习的协同过滤可谓百花齐放、百家争鸣。更有学者提出“万物皆可Embedding”。那么问题来了什么是Embedding呢
Embedding是个英文术语如果非要找一个中文翻译对照的话我觉得“向量化”Vectorize最合适。Embedding的过程就是把数据集合映射到向量空间进而把数据进行向量化的过程。这句话听上去有些玄乎我换个更好懂的说法Embedding的目标就是找到一组合适的向量来刻画现有的数据集合。
以GarageType字段为例它有6个取值也就是说我们总共有6种车库类型。那么对于这6个字符串来说我们该如何用数字化的方式来表示它们呢毕竟模型只能消费数值不能直接消费字符串。
![图片](https://static001.geekbang.org/resource/image/3d/35/3d01765800f906d5f566676d396eba35.jpg?wh=1920x769 "GarageType的6个取值")
一种方法是采用预处理环节的StringIndexer把字符串转换为连续的整数然后让模型去消费这些整数。在理论上这么做没有任何问题。但从模型的效果出发整数的表达方式并不合理。为什么这么说呢
我们知道连续整数之间是存在比较关系的比如1 < 36 > 5等等。但是原始的字符串之间比如“Attchd”与“Detchd”并不存在大小关系如果强行用0表示“Attchd”、用1表示“Detchd”逻辑上就会出现“Attchd”<“Detchd”的悖论。
因此预处理环节的StringIndexer仅仅是把字符串转换为数字转换得到的数值是不能直接喂给模型做训练。我们需要把这些数字进一步向量化才能交给模型去消费。那么问题来了对于StringIndexer输出的数值我们该怎么对他们进行向量化呢这就要用到Embedding了。
作为入门课,咱们不妨从最简单的**热独编码One Hot Encoding**开始去认识Embedding并掌握它的基本用法。我们先来说说热独编码是怎么一回事。相比照本宣科说概念咱们不妨以GarageType为例从示例入手你反而更容易心领神会。
![图片](https://static001.geekbang.org/resource/image/29/04/29099068173252b7988a8409dc5bb204.jpg?wh=1920x955 "热独编码示例")
首先通过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把索引字段进一步转换为向量字段。
```scala
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之上完成转换。
好啦到此为止我们以OneHotEncoder为代表学习了Spark MLlib框架中Embedding的用法初步打通了特征工程的第五关。
尽管还有很多其他Embedding方法需要我们进一步探索不过从入门的角度来说OneHotEncoder完全可以应对大部分机器学习应用。
![图片](https://static001.geekbang.org/resource/image/73/6f/73ab34506811e943613d93582f40646f.jpg?wh=1920x871 "打卡特征工程第五关Embedding")
### 向量计算
打通第五关之后,特征工程“这套游戏”还剩下最后一道关卡:向量计算。
**向量计算作为特征工程的最后一个环节主要用于构建训练样本中的特征向量Feature Vectors**。在Spark MLlib框架下训练样本由两部分构成第一部分是预测标的Label在“房价预测”的项目中Label是房价。
而第二部分就是特征向量在形式上特征向量可以看作是元素类型为Double的数组。根据前面的特征工程流程图我们不难发现特征向量的构成来源多种多样比如原始的数值字段、归一化或是离散化之后的数值字段、以及向量化之后的特征字段等等。
Spark MLlib在向量计算方面提供了丰富的支持比如前面介绍过的、用于集成特征向量的VectorAssembler用于对向量做剪裁的VectorSlicer以元素为单位做乘法的ElementwiseProduct等等。灵活地运用这些函数我们可以随意地组装特征向量从而构建模型所需的训练样本。
在前面的几个环节中预处理、特征选择、归一化、离散化、Embedding我们尝试对数值和非数值类型特征做各式各样的转换目的在于探索可能对预测标的影响更大的潜在因素。
接下来我们使用VectorAssembler将这些潜在因素全部拼接在一起、构建特征向量从而为后续的模型训练准备好训练样本。
```scala
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来指定特征向量与预测标的定义出线性回归模型。
```scala
// 定义线性回归模型
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}")
```
好啦,到此为止,我们打通了特征工程所有关卡,恭喜你!尽管不少关卡还有待我们进一步去深入探索,但这并不影响我们从整体上把握特征工程,构建结构化的知识体系。对于没讲到的函数与技巧,你完全可以利用自己的碎片时间,借鉴这两节课我给你梳理的学习思路,来慢慢地将它们补齐,加油!
![图片](https://static001.geekbang.org/resource/image/7c/24/7c6397186f48ce07679f3ef63c6e4524.jpg?wh=1920x824 "通关!")
## 通关奖励:模型效果对比
学习过VectorAssembler的用法之后你会发现特征工程任一环节的输出都可以用来构建特征向量从而用于模型训练。在介绍特征工程的部分我们花了大量篇幅介绍不同环节的作用与用法。
你可能会好奇:“这些不同环节的特征处理,真的会对模型效果有帮助吗?毕竟,折腾了半天,我们还是要看模型效果的”。
没错,特征工程的最终目的,是调优模型效果。接下来,通过将不同环节输出的训练样本喂给模型,我们来对比不同特征处理方法对应的模型效果。
![图片](https://static001.geekbang.org/resource/image/80/0b/8023260593e5552ce7ea6eb1e868c30b.jpeg?wh=1920x974 "特征工程不同环节优化过后的模型效果")
不同环节对应的代码地址如下:
1.[调优对比基准](https://github.com/wulei-bj-cn/learn-spark/blob/main/chapter25/v0-baseline.scala)
2.[特征工程-调优1](https://github.com/wulei-bj-cn/learn-spark/blob/main/chapter25/v1-numeric-features.scala)
3.[特征工程-调优2](https://github.com/wulei-bj-cn/learn-spark/blob/main/chapter25/v2-selected-features.scala)
4.[特征工程-调优3](https://github.com/wulei-bj-cn/learn-spark/blob/main/chapter25/v3-scaled-features.scala)
5.[特征工程-调优4](https://github.com/wulei-bj-cn/learn-spark/blob/main/chapter25/v4-with-bucketized-feature.scala)
6.[特征工程-调优5](https://github.com/wulei-bj-cn/learn-spark/blob/main/chapter25/v5-with-embedding.scala)
7.[特征工程-调优6](https://github.com/wulei-bj-cn/learn-spark/blob/main/chapter25/v6-all-applied.scala)
可以看到,随着特征工程的推进,模型在训练集上的预测误差越来越小,这说明模型的拟合能力越来越强,而这也就意味着,特征工程确实有助于模型性能的提升。
对应特征工程不同环节的训练代码,我整理到了最后的“[代码地址](https://github.com/wulei-bj-cn/learn-spark/tree/main/chapter25)”那一列。强烈建议你动手运行这些代码,对比不同环节的特征处理方法,以及对应的模型效果。
当然,我们在评估模型效果的时候,不能仅仅关注它的拟合能力,更重要的是模型的泛化能力。拟合能力强,只能说明模型在训练集上的预测误差足够小;而泛化能力,量化的是模型在测试集上的预测误差。换句话说,泛化能力的含义是,模型在一份“未曾谋面”的数据集上表现如何。
这一讲,咱们的重点是特征工程,因此暂时忽略了模型在测试集上的表现。从下一讲的模型训练开始,对于模型效果,我们将同时关注模型这两方面的能力:拟合与泛化。
## 重点回顾
好啦今天的内容讲完啦我们一起来做个总结。今天这一讲我们主要围绕着特征工程中的离散化、Embedding和向量计算展开你需要掌握其中最具代表性的特征处理函数。
到此为止Spark MLlib特征工程中涉及的6大类特征处理函数我们就都讲完了。为了让你对他们有一个整体上的把握同时能够随时回顾不同环节的作用与效果我把每一个大类的特点、以及咱们讲过的处理函数都整理到了如下的表格中供你参考。
![图片](https://static001.geekbang.org/resource/image/26/1e/261971f0b0177fcd04ed2b9415d69f1e.jpg?wh=1920x1098)
今天的内容很多需要我们多花时间去消化。受2/8理论的支配在机器学习实践中特征工程往往会花费我们80%的时间和精力。由于特征工程制约着模型效果的上限,因此,尽管特征工程的步骤繁多、过程繁琐,但是我们千万不能在这个环节偷懒,一定要认真对待。
这也是为什么我们分为上、下两部分来着重讲解特征工程,从概览到每一个环节,从每一个环节的作用到它包含的具体方法。数据质量构筑了模型效果的天花板,特征工程道阻且长,然而行则将至,让我们一起加油!
## 每课一练
结合上一讲对于我们介绍过的所有特征处理函数如StringIndexer、ChiSqSelector、MinMaxScaler、Bucketizer、OneHotEncoder和VectorAssembler你能说说他们之间的区别和共同点吗
欢迎你在留言区记录你的收获与思考,也欢迎你向更多同事、朋友分享今天的内容,说不定就能帮他解决特征工程方面的问题。