原文:
annas-archive.org/md5/8cd899a961e3fea998473427a1ba1c82
译者:飞龙
第八章:分析非结构化数据
在大数据时代,非结构化数据的激增让人不堪重负。存在多种方法(如数据挖掘、自然语言处理(NLP)、信息检索等)来分析非结构化数据。由于各行各业中非结构化数据的快速增长,具备可扩展性的解决方案已经成为当务之急。Apache Spark 配备了现成的文本分析算法,同时也支持自定义开发默认不提供的算法。
在上一章中,我们展示了如何通过 SparkR(一个为 R 程序员提供的 Spark R API)利用 Spark 的强大功能,而无需学习一门新语言。在这一章中,我们将进入一个全新的维度,探索利用 Spark 从非结构化数据中提取信息的算法和技术。
作为本章的前提条件,具备 Python 或 Scala 编程的基本知识,以及对文本分析和机器学习的整体理解将是非常有帮助的。不过,我们已经通过合适的实践示例覆盖了一些理论基础,使得这些内容更易于理解和实施。本章涵盖的主题包括:
-
非结构化数据的来源
-
处理非结构化数据
-
计数向量化
-
TF-IDF
-
停用词去除
-
标准化/缩放
-
Word2Vec
-
n-gram 建模
-
-
文本分类
- 朴素贝叶斯分类器
-
文本聚类
- K 均值算法
-
降维
-
奇异值分解
-
主成分分析
-
-
小结
非结构化数据的来源
数据分析自八十年代和九十年代的电子表格和 BI 工具以来,已经取得了长足进展。计算能力的巨大提升、复杂的算法以及开源文化推动了数据分析和其他领域的前所未有的增长。这些技术进步为新的机遇和挑战铺平了道路。企业开始着眼于从以往无法处理的数据源(如内部备忘录、电子邮件、客户满意度调查等)中生成洞察。如今,数据分析不仅仅局限于传统的行列数据,还涵盖了这种非结构化的、通常以文本为基础的数据。存储在关系数据库管理系统(RDBMS)中的高度结构化数据与完全非结构化的纯文本之间,我们有半结构化数据源,如 NoSQL 数据存储、XML 或 JSON 文档,以及图形或网络数据源。据当前估计,非结构化数据约占企业数据的 80%,且正在迅速增长。卫星图像、大气数据、社交网络、博客及其他网页、病历和医生记录、公司内部通讯等——这些只是非结构化数据来源的一部分。
我们已经看到了成功的数据产品,它们将非结构化数据与结构化数据相结合。一些公司利用社交网络的力量,为客户提供可操作的见解。像情感分析和多媒体分析这样的新领域正在涌现,以从非结构化数据中提取见解。然而,分析非结构化数据仍然是一项艰巨的任务。例如,现代的文本分析工具和技术无法识别讽刺。然而,潜在的好处无疑大于这些局限性。
处理非结构化数据
非结构化数据不适合大多数编程任务。必须根据不同情况以多种方式处理它,才能作为任何机器学习算法的输入或进行可视化分析。大致而言,非结构化数据分析可以视为一系列步骤,如下图所示:
https://github.com/OpenDocCN/freelearn-ds-pt5-zh/raw/master/docs/spk-ds/img/image_08_001.jpg
数据预处理是任何非结构化数据分析中最关键的步骤。幸运的是,随着时间的推移,已经积累了多种经过验证的技术,这些技术非常实用。Spark 通过ml.features
包提供了大部分这些技术。大多数技术的目的是将文本数据转换为简洁的数值向量,这些向量可以被机器学习算法轻松处理。开发人员应该理解其组织的具体需求,从而制定最佳的预处理工作流程。请记住,更好、更相关的数据是生成更好见解的关键。
让我们探索几个处理原始文本并将其转换为数据框的示例。第一个示例将一些文本作为输入,提取所有类似日期的字符串,而第二个示例则从 Twitter 文本中提取标签。第一个示例只是一个热身,使用一个简单的正则表达式(regex)标记器特征转换器,而没有使用任何 Spark 特定的库。它还引起你对误解可能性的关注。例如,格式为 1-11-1111 的产品代码可能被解释为日期。第二个示例展示了一个非平凡的、多步骤的提取过程,最终只提取了所需的标签。用户定义函数(udf)和机器学习管道在开发这种多步骤提取过程中非常有用。本节的剩余部分介绍了 Apache Spark 中提供的一些其他方便的工具。
示例-1: 从文本中提取类似日期的字符串
Scala:
scala> import org.apache.spark.ml.feature.RegexTokenizer
import org.apache.spark.ml.feature.RegexTokenizer
scala> val date_pattern: String = "\\d{1,4}[/ -]\\d{1,4}[/ -]\\d{1,4}"
date_pattern: String = \d{1,4}[/ -]\d{1,4}[/ -]\d{1,4}
scala> val textDF = spark.createDataFrame(Seq(
(1, "Hello 1996-12-12 this 1-21-1111 is a 18-9-96 text "),
(2, "string with dates in different 01/02/89 formats"))).
toDF("LineNo","Text")
textDF: org.apache.spark.sql.DataFrame = [LineNo: int, Text: string]
scala> val date_regex = new RegexTokenizer().
setInputCol("Text").setOutputCol("dateStr").
setPattern(date_pattern).setGaps(false)
date_regex: org.apache.spark.ml.feature.RegexTokenizer = regexTok_acdbca6d1c4c
scala> date_regex.transform(textDF).select("dateStr").show(false)
+--------------------------------+
|dateStr |
+--------------------------------+
|[1996-12-12, 1-21-1111, 18-9-96]|
|[01/02/89] |
+--------------------------------+
Python:
// Example-1: Extract date like strings from text
>>> from pyspark.ml.feature import RegexTokenizer
>>> date_pattern = "\\d{1,4}[/ -]\\d{1,4}[/ -]\\d{1,4}"
>>> textDF = spark.createDataFrame([
[1, "Hello 1996-12-12 this 1-21-1111 is a 18-9-96 text "],
[2, "string with dates in different 01/02/89 formats"]]).toDF(
"LineNo","Text")
>>> date_regex = RegexTokenizer(inputCol="Text",outputCol="dateStr",
gaps=False, pattern=date_pattern)
>>> date_regex.transform(textDF).select("dateStr").show(5,False)
+--------------------------------+
|dateStr |
+--------------------------------+
|[1996-12-12, 1-21-1111, 18-9-96]|
|[01/02/89] |
+--------------------------------+
上面的示例定义了一个正则表达式模式来识别日期字符串。正则表达式模式和示例文本数据框被传递到RegexTokenizer
中以提取匹配的类似日期的字符串。gaps=False
选项选择匹配的字符串,False
值会将给定的模式用作分隔符。注意,1-21-1111
,显然不是日期,也被选中。
下一个示例从 Twitter 文本中提取标签,并识别最流行的标签。你也可以使用相同的方法收集哈希(#
)标签。
这个示例使用了内建函数explode
,它将一个包含数组值的单行数据转换为多行,每行包含一个数组元素的值。
示例-2:从 Twitter 文本中提取标签
Scala:
//Step1: Load text containing @ from source file
scala> val path = "<Your path>/tweets.json"
path: String = <Your path>/tweets.json
scala> val raw_df = spark.read.text(path).filter($"value".contains("@"))
raw_df: org.apache.spark.sql.Dataset[org.apache.spark.sql.Row] = [value: string]
//Step2: Split the text to words and filter out non-tag words
scala> val df1 = raw_df.select(explode(split('value, " ")).as("word")).
filter($"word".startsWith("@"))
df1: org.apache.spark.sql.Dataset[org.apache.spark.sql.Row] = [word: string]
//Step3: compute tag-wise counts and report top 5
scala> df1.groupBy($"word").agg(count($"word")).
orderBy($"count(word)".desc).show(5)
+------------+-----------+
+
| word|count(word)|
+------------+-----------+
|@ApacheSpark| 15|
| @SSKapci| 9|
|@databricks:| 4|
| @hadoop| 4|
| @ApacheApex| 4|
+------------+-----------+
Python:
>> from pyspark.sql.functions import explode, split
//Step1: Load text containing @ from source file
>>> path ="<Your path>/tweets.json"
>>> raw_df1 = spark.read.text(path)
>>> raw_df = raw_df1.where("value like '%@%'")
>>>
//Step2: Split the text to words and filter out non-tag words
>>> df = raw_df.select(explode(split("value"," ")))
>>> df1 = df.where("col like '@%'").toDF("word")
>>>
//Step3: compute tag-wise counts and report top 5
>>> df1.groupBy("word").count().sort(
"count",ascending=False).show(5)
+------------+-----+
+
| word|count|
+------------+-----+
|@ApacheSpark| 15|
| @SSKapci| 9|
|@databricks:| 4|
| @ApacheApex| 4|
| @hadoop| 4|
+------------+-----+
计数向量化
计数向量化从文档中提取词汇(词元),并在没有预定义字典的情况下生成CountVectorizerModel
模型。顾名思义,文本文档被转换为包含词元和计数的向量。该模型产生词汇表上文档的稀疏表示。
你可以根据业务需求精细调整行为,限制词汇表大小、最小词元计数等。
//示例 3:计数向量化示例
Scala
scala> import org.apache.spark.ml.feature.{CountVectorizer, CountVectorizerModel}
import org.apache.spark.ml.feature.{CountVectorizer, CountVectorizerModel}
scala> import org.apache.spark.sql.DataFrame
import org.apache.spark.sql.DataFrame
scala> import org.apache.spark.ml.linalg.Vector
import org.apache.spark.ml.linalg.Vector
scala> val df: DataFrame = spark.createDataFrame(Seq(
(0, Array("ant", "bat", "cat", "dog", "eel")),
(1, Array("dog","bat", "ant", "bat", "cat"))
)).toDF("id", "words")
df: org.apache.spark.sql.DataFrame = [id: int, words: array<string>]
scala>
// Fit a CountVectorizerModel from the corpus
// Minimum occurrences (DF) is 2 and pick 10 top words(vocabsize) only scala> val cvModel: CountVectorizerModel = new CountVectorizer().
setInputCol("words").setOutputCol("features").
setMinDF(2).setVocabSize(10).fit(df)
cvModel: org.apache.spark.ml.feature.CountVectorizerModel = cntVec_7e79157ba561
// Check vocabulary. Words are arranged as per frequency
// eel is dropped because it is below minDF = 2 scala> cvModel.vocabulary
res6: Array[String] = Array(bat, dog, cat, ant)
//Apply the model on document
scala> val cvDF: DataFrame = cvModel.transform(df)
cvDF: org.apache.spark.sql.DataFrame = [id: int, words: array<string> ... 1 more field]
//Check the word count scala> cvDF.select("features").collect().foreach(row =>
println(row(0).asInstanceOf[Vector].toDense))
[1.0,1.0,1.0,1.0]
[2.0,1.0,1.0,1.0]
Python:
>>> from pyspark.ml.feature import CountVectorizer,CountVectorizerModel
>>> from pyspark.ml.linalg import Vector
>>>
// Define source DataFrame
>>> df = spark.createDataFrame([
[0, ["ant", "bat", "cat", "dog", "eel"]],
[1, ["dog","bat", "ant", "bat", "cat"]]
]).toDF("id", "words")
>>>
// Fit a CountVectorizerModel from the corpus
// Minimum occorrences (DF) is 2 and pick 10 top words(vocabsize) only
>>> cvModel = CountVectorizer(inputCol="words", outputCol="features",
minDF = 2, vocabSize = 10).fit(df)
>>>
// Check vocabulary. Words are arranged as per frequency
// eel is dropped because it is below minDF = 2
>>> cvModel.vocabulary
[u'bat', u'ant', u'cat', u'dog']
//Apply the model on document
>>> cvDF = cvModel.transform(df)
//Check the word count
>>> cvDF.show(2,False)
+---+-------------------------+-------------------------------+
|id |words |features |
+---+-------------------------+-------------------------------+
|0 |[ant, bat, cat, dog, eel]|(4,[0,1,2,3],[1.0,1.0,1.0,1.0])|
|1 |[dog, bat, ant, bat, cat]|(4,[0,1,2,3],[2.0,1.0,1.0,1.0])|
+---+-------------------------+-------------------------------+
输入:
|id | text
+---+-------------------------+-------------------------------+
|0 | "ant", "bat", "cat", "dog", "eel"
|1 | "dog","bat", "ant", "bat", "cat"
输出:
id| text | Vector
--|------------------------------------|--------------------
0 | "ant", "bat", "cat", "dog", "eel" |[1.0,1.0,1.0,1.0]
1 | "dog","bat", "ant", "bat", "cat" |[2.0,1.0,1.0,1.0]
上面的示例演示了CountVectorizer
如何作为估计器提取词汇并生成CountVectorizerModel
模型。请注意,特征向量的顺序对应于词汇表而非输入序列。我们还可以看看如何通过预先构建字典来实现相同的功能。然而,请记住,它们各自有不同的使用场景。
示例 4:使用预先定义的词汇表定义 CountVectorizerModel
Scala:
// Example 4: define CountVectorizerModel with a-priori vocabulary
scala> val cvm: CountVectorizerModel = new CountVectorizerModel(
Array("ant", "bat", "cat")).
setInputCol("words").setOutputCol("features")
cvm: org.apache.spark.ml.feature.CountVectorizerModel = cntVecModel_ecbb8e1778d5
//Apply on the same data. Feature order corresponds to a-priory vocabulary order scala> cvm.transform(df).select("features").collect().foreach(row =>
println(row(0).asInstanceOf[Vector].toDense))
[1.0,1.0,1.0]
[1.0,2.0,1.0]
Python:
在 Spark 2.0.0 版本中不可用
TF-IDF
词频-逆文档频率(TF-IDF)可能是文本分析中最常用的度量之一。该度量表示某一术语在一组文档中的重要性。它由两个度量组成,词频(TF)和逆文档频率(IDF)。让我们逐一讨论它们,然后看看它们的结合效果。
TF 是衡量术语在文档中相对重要性的指标,通常是该术语在文档中出现的频率除以文档中的词数。假设一个文本文档包含 100 个单词,其中词apple出现了 8 次。则apple的 TF 为TF = (8 / 100) = 0.08。因此,术语在文档中出现的频率越高,它的 TF 系数越大。
IDF 是衡量特定术语在整个文档集合中重要性的指标,即该词在所有文档中出现的频率。术语的重要性与其出现频率成反比。Spark 提供了两种不同的方法来执行这些任务。假设我们有 600 万个文档,词apple出现在其中 6000 个文档中。那么,IDF 可以计算为IDF = Log(6,000,000 / 6,000) = 3。仔细观察可以发现,分母越小,IDF 值越高。这意味着包含特定词汇的文档越少,它的重要性越高。
因此,TF-IDF 分数将是TF * IDF = 0.08 * 3 = 0.24。请注意,它会对那些在文档中出现频率较高但不太重要的单词(如 the、this、a 等)进行惩罚,而给那些重要的单词赋予更高的权重。
在 Spark 中,TF 实现为 HashingTF。它接受一个术语序列(通常是分词器的输出),并生成一个固定长度的特征向量。它通过特征哈希将术语转换为固定长度的索引。然后,IDF 会将该特征向量(HashingTF 的输出)作为输入,并根据文档集中的术语频率对其进行缩放。上一章有这个转换的示例。
停用词移除
常见的单词,如 is、was 和 the,被称为停用词。它们通常不会为分析增加价值,应在数据准备步骤中删除。Spark 提供了 StopWordsRemover
转换器,专门做这件事。它接受一系列字符串输入的标记(如分词器的输出),并移除所有停用词。Spark 默认提供一个停用词列表,你可以通过提供自己的停用词列表来覆盖它。你还可以选择启用 caseSensitive
匹配,默认情况下该选项为关闭状态。
示例 5:停用词移除器
Scala:
scala> import org.apache.spark.ml.feature.StopWordsRemover
import org.apache.spark.ml.feature.StopWordsRemover
scala> import org.apache.spark.sql.DataFrame
import org.apache.spark.sql.DataFrame
scala> import org.apache.spark.ml.linalg.Vector
import org.apache.spark.ml.linalg.Vector
scala> val rawdataDF = spark.createDataFrame(Seq(
(0, Array("I", "ate", "the", "cake")),
(1, Array("John ", "had", "a", " tennis", "racquet")))).
toDF("id","raw_text")
rawdataDF: org.apache.spark.sql.DataFrame = [id: int, raw_text: array<string>]
scala> val remover = new StopWordsRemover().setInputCol("raw_text").
setOutputCol("processed_text")
remover: org.apache.spark.ml.feature.StopWordsRemover = stopWords_55edbac88edb
scala> remover.transform(rawdataDF).show(truncate=false)
+---+---------------------------------+-------------------------+
|id |raw_text |processed_text |
+---+---------------------------------+-------------------------+
|0 |[I, ate, the, cake] |[ate, cake] |
|1 |[John , had, a, tennis, racquet]|[John , tennis, racquet]|
+---+---------------------------------+-------------------------+
Python:
>>> from pyspark.ml.feature import StopWordsRemover
>>> RawData = sqlContext.createDataFrame([
(0, ["I", "ate", "the", "cake"]),
(1, ["John ", "had", "a", " tennis", "racquet"])
], ["id", "raw_text"])
>>>
>>> remover = StopWordsRemover(inputCol="raw_text",
outputCol="processed_text")
>>> remover.transform(RawData).show(truncate=False)
+---+---------------------------------+-------------------------+
|id |raw_text |processed_text |
+---+---------------------------------+-------------------------+
|0 |[I, ate, the, cake] |[ate, cake] |
|1 |[John , had, a, tennis, racquet]|[John , tennis, racquet]|
+---+---------------------------------+-------------------------+
假设我们有如下的 DataFrame,其中包含 id
和 raw_text
列:
id | raw_text
----|----------
0 | [I, ate, the, cake]
1 | [John, had, a, tennis, racquet]
在应用 StopWordsRemover
时,以 raw_text
作为输入列,processed_text
作为输出列,针对上述示例,我们应该得到以下输出:
id | raw_text | processed_text
----|--------------------------------|--------------------
0 | [I, ate, the, cake] | [ate, cake]
1 |[John, had, a, tennis, racquet] |[John, tennis, racquet]
归一化/缩放
归一化是数据准备中的常见和初步步骤。当所有特征处于相同尺度时,大多数机器学习算法效果更好。例如,如果有两个特征,其中一个值比另一个大约高出 100 倍,将它们调整到相同的尺度可以反映这两个变量之间有意义的相对活动。任何非数值类型的值,如高、中、低,理想情况下应该转换为适当的数值量化,这是最佳实践。然而,在进行转换时需要小心,因为这可能需要领域专业知识。例如,如果你为高、中、低分别分配 3、2 和 1,那么应该检查这三个单位是否等距。
特征归一化的常见方法有缩放、均值减法和特征标准化,这里只列举几个。缩放中,每个数值特征向量都会被重新缩放,使其值的范围介于*-1* 到 +1 或 0 到 1 之间,或类似的范围。在均值减法中,你计算一个数值特征向量的均值,并从每个值中减去这个均值。我们关注的是相对于均值的偏差,而绝对值可能不重要。特征标准化指的是将数据设置为零均值和单位(1)方差。
Spark 提供了一个 Normalizer
特征转换器,用于将每个向量规范化为单位范数;StandardScaler
用于规范化为单位范数且均值为零;MinMaxScaler
用于将每个特征缩放到特定的值范围。默认情况下,最小值和最大值为 0 和 1,但你可以根据数据需求自行设置值参数。
Word2Vec
Word2Vec 是一种主成分分析(PCA)(稍后你会更多了解)方法,它接收一个单词序列并生成一个映射(字符串,向量)。字符串是单词,向量是唯一的固定大小向量。生成的单词向量表示在许多机器学习和自然语言处理应用中非常有用,例如命名实体识别和标注。让我们来看一个示例。
示例 6:Word2Vec
Scala
scala> import org.apache.spark.ml.feature.Word2Vec
import org.apache.spark.ml.feature.Word2Vec
//Step1: Load text file and split to words scala> val path = "<Your path>/RobertFrost.txt"
path: String = <Your path>/RobertFrost.txt
scala> val raw_text = spark.read.text(path).select(
split('value, " ") as "words")
raw_text: org.apache.spark.sql.DataFrame = [words: array<string>]
//Step2: Prepare features vector of size 4 scala> val resultDF = new Word2Vec().setInputCol("words").
setOutputCol("features").setVectorSize(4).
setMinCount(2).fit(raw_text).transform(raw_text)
resultDF: org.apache.spark.sql.DataFrame = [words: array<string>, features: vector]
//Examine results scala> resultDF.show(5)
+--------------------+--------------------+
| words| features|
+--------------------+--------------------+
|[Whose, woods, th...|[-0.0209098898340...|
|[His, house, is, ...|[-0.0013444167044...|
|[He, will, not, s...|[-0.0058525378408...|
|[To, watch, his, ...|[-0.0189630933296...|
|[My, little, hors...|[-0.0084691265597...|
+--------------------+--------------------+
Python:
>>> from pyspark.ml.feature import Word2Vec
>>> from pyspark.sql.functions import explode, split
>>>
//Step1: Load text file and split to words >>> path = "<Your path>/RobertFrost.txt"
>>> raw_text = spark.read.text(path).select(
split("value"," ")).toDF("words")
//Step2: Prepare features vector of size 4 >>> resultDF = Word2Vec(inputCol="words",outputCol="features",
vectorSize=4, minCount=2).fit(
raw_text).transform(raw_text)
//Examine results scala> resultDF.show(5)
+--------------------+--------------------+
| words| features|
+--------------------+--------------------+
|[Whose, woods, th...|[-0.0209098898340...|
|[His, house, is, ...|[-0.0013444167044...|
|[He, will, not, s...|[-0.0058525378408...|
|[To, watch, his, ...|[-0.0189630933296...|
|[My, little, hors...|[-0.0084691265597...|
+--------------------+--------------------+
n-gram 建模
n-gram 是一个由给定文本或语音序列中的 n 项连续组成的序列。大小为 1 的 n-gram 称为 unigram,大小为 2 的称为 bigram,大小为 3 的称为 trigram。或者,它们也可以按 n 的值进行命名,例如四元组、五元组,依此类推。让我们看一个示例,以了解该模型可能的输出:
input |1-gram sequence | 2-gram sequence | 3-gram sequence
-------|-----------------|-----------------|---------------
apple | a,p,p,l,e | ap,pp,pl,le | app,ppl,ple
这是一个将单词转换为 n-gram 字母的示例。同样的情况也适用于将句子(或分词后的单词)转换为 n-gram 单词。例如,句子 孩子们喜欢吃巧克力 的 2-gram 等效形式是:
‘孩子们喜欢’,‘喜欢’,‘吃’,‘吃巧克力’。
n-gram 建模在文本挖掘和自然语言处理中的应用非常广泛。一个例子是根据先前的上下文预测每个单词出现的概率(条件概率)。
在 Spark 中,NGram
是一个特征转换器,它将输入数组(例如,Tokenizer 的输出)中的字符串转换为一个 n-gram 数组。默认情况下,输入数组中的空值会被忽略。它返回一个由 n-gram 组成的数组,其中每个 n-gram 是由空格分隔的单词字符串表示的。
示例 7:NGram
Scala
scala> import org.apache.spark.ml.feature.NGram
import org.apache.spark.ml.feature.NGram
scala> val wordDF = spark.createDataFrame(Seq(
(0, Array("Hi", "I", "am", "a", "Scientist")),
(1, Array("I", "am", "just", "learning", "Spark")),
(2, Array("Coding", "in", "Scala", "is", "easy"))
)).toDF("label", "words")
//Create an ngram model with 3 words length (default is 2) scala> val ngramModel = new NGram().setInputCol(
"words").setOutputCol("ngrams").setN(3)
ngramModel: org.apache.spark.ml.feature.NGram = ngram_dc50209cf693
//Apply on input data frame scala> ngramModel.transform(wordDF).select("ngrams").show(false)
+--------------------------------------------------+
|ngrams |
+--------------------------------------------------+
|[Hi I am, I am a, am a Scientist] |
|[I am just, am just learning, just learning Spark]|
|[Coding in Scala, in Scala is, Scala is easy] |
+--------------------------------------------------+
//Apply the model on another dataframe, Word2Vec raw_text scala>ngramModel.transform(raw_text).select("ngrams").take(1).foreach(println)
[WrappedArray(Whose woods these, woods these are, these are I, are I think, I think I, think I know.)]
Python:
>>> from pyspark.ml.feature import NGram
>>> wordDF = spark.createDataFrame([
[0, ["Hi", "I", "am", "a", "Scientist"]],
[1, ["I", "am", "just", "learning", "Spark"]],
[2, ["Coding", "in", "Scala", "is", "easy"]]
]).toDF("label", "words")
//Create an ngram model with 3 words length (default is 2) >>> ngramModel = NGram(inputCol="words", outputCol= "ngrams",n=3)
>>>
//Apply on input data frame >>> ngramModel.transform(wordDF).select("ngrams").show(4,False)
+--------------------------------------------------+
|ngrams |
+--------------------------------------------------+
|[Hi I am, I am a, am a Scientist] |
|[I am just, am just learning, just learning Spark]|
|[Coding in Scala, in Scala is, Scala is easy] |
+--------------------------------------------------+
//Apply the model on another dataframe from Word2Vec example >>> ngramModel.transform(resultDF).select("ngrams").take(1)
[Row(ngrams=[u'Whose woods these', u'woods these are', u'these are I', u'are I think', u'I think I', u'think I know.'])]
文本分类
文本分类是将一个主题、学科类别、类型或类似内容分配给文本块。例如,垃圾邮件过滤器会将邮件标记为垃圾邮件或非垃圾邮件。
Apache Spark 通过 MLlib 和 ML 包支持各种分类器。SVM 分类器和朴素贝叶斯分类器是常用的分类器,前者已在前一章中讲解过。现在让我们来看后者。
朴素贝叶斯分类器
朴素贝叶斯 (NB) 分类器是一种多类别的概率分类器,是最好的分类算法之一。它假设每对特征之间具有强独立性。它计算每个特征和给定标签的条件概率分布,然后应用贝叶斯定理计算给定观察值下标签的条件概率。在文档分类中,一个观察值就是待分类的文档。尽管它对数据有较强的假设,它仍然非常流行。它适用于少量训练数据——无论是真实数据还是离散数据。它工作非常高效,因为它只需要通过训练数据进行一次遍历;唯一的限制是特征向量必须是非负的。默认情况下,机器学习包支持多项式朴素贝叶斯。然而,如果需要伯努利朴素贝叶斯,可以将参数 modelType
设置为 Bernoulli
。
拉普拉斯平滑 技术可以通过指定平滑参数来应用,在需要为稀有词或新词分配一个小的非零概率的情况下,它非常有用,以避免后验概率突然降至零。
Spark 还提供了一些其他的超参数,如 thresholds
,以便获得更细粒度的控制。以下是一个分类推特文本的示例。该示例包含一些手工编写的规则,用于为训练数据分配类别。如果文本中包含对应的词语,则会分配特定类别。例如,如果文本包含“survey”或“poll”,则类别为“survey”。模型基于这些训练数据进行训练,并在不同时间收集的不同文本样本上进行评估:
示例 8:朴素贝叶斯
Scala:
// Step 1: Define a udf to assign a category // One or more similar words are treated as one category (eg survey, poll)
// If input list contains any of the words in a category list, it is assigned to that category
// "General" is assigned if none of the categories matched
scala> import scala.collection.mutable.WrappedArray
import scala.collection.mutable.WrappedArray
scala> val findCategory = udf ((words: WrappedArray[String]) =>
{ var idx = 0; var category : String = ""
val categories : List[Array[String]] = List(
Array("Python"), Array("Hadoop","hadoop"),
Array("survey","poll"),
Array("event","training", "Meetup", "summit",
"talk", "talks", "Setting","sessions", "workshop"),
Array("resource","Guide","newsletter", "Blog"))
while(idx < categories.length && category.isEmpty ) {
if (!words.intersect(categories(idx)).isEmpty) {
category = categories(idx)(0) } //First word in the category list
idx += 1 }
if (category.isEmpty) {
category = "General" }
category
})
findCategory: org.apache.spark.sql.expressions.UserDefinedFunction = UserDefinedFunction(<function1>,StringType,Some(List(ArrayType(StringType,true))))
//UDF to convert category to a numerical label scala> val idxCategory = udf ((category: String) =>
{val catgMap = Map({"General"->1},{"event"->2},{"Hadoop"->3},
{"Python"->4},{"resource"->5})
catgMap(category)})
idxCategory: org.apache.spark.sql.expressions.UserDefinedFunction =
UserDefinedFunction(<function1>,IntegerType,Some(List(StringType)))
scala> val labels = Array("General","event","Hadoop","Python","resource")
//Step 2: Prepare train data
//Step 2a: Extract "text" data and split to words scala> val path = "<Your path>/tweets_train.txt"
path: String = <Your path>../work/tweets_train.txt
scala> val pattern = ""text":"
pattern: String = "text":
scala> val raw_text = spark.read.text(path).filter($"value".contains(pattern)).
select(split('value, " ") as "words")
raw_text: org.apache.spark.sql.DataFrame = [words: array<string>]
scala>
//Step 2b: Assign a category to each line scala> val train_cat_df = raw_text.withColumn("category",
findCategory(raw_text("words"))).withColumn("label",idxCategory($"category"))
train_cat_df: org.apache.spark.sql.DataFrame = [words: array<string>, category:
string ... 1 more field]
//Step 2c: Examine categories scala> train_cat_df.groupBy($"category").agg(count("category")).show()
+--------+---------------+
|category|count(category)|
+--------+---------------+
| General| 146|
|resource| 1|
| Python| 2|
| event| 10|
| Hadoop| 6|
+--------+---------------+
//Step 3: Build pipeline scala> import org.apache.spark.ml.Pipeline
import org.apache.spark.ml.Pipeline
scala> import org.apache.spark.ml.feature.{StopWordsRemover, CountVectorizer,
IndexToString}
import org.apache.spark.ml.feature.{StopWordsRemover, CountVectorizer,
StringIndexer, IndexToString}
scala> import org.apache.spark.ml.classification.NaiveBayes
import org.apache.spark.ml.classification.NaiveBayes
scala>
//Step 3a: Define pipeline stages
//Stop words should be removed first scala> val stopw = new StopWordsRemover().setInputCol("words").
setOutputCol("processed_words")
stopw: org.apache.spark.ml.feature.StopWordsRemover = stopWords_2fb707daa92e
//Terms to term frequency converter scala> val cv = new CountVectorizer().setInputCol("processed_words").
setOutputCol("features")
cv: org.apache.spark.ml.feature.CountVectorizer = cntVec_def4911aa0bf
//Define model scala> val model = new NaiveBayes().
setFeaturesCol("features").
setLabelCol("label")
model: org.apache.spark.ml.classification.NaiveBayes = nb_f2b6c423f12c
//Numerical prediction label to category converter scala> val lc = new IndexToString().setInputCol("prediction").
setOutputCol("predictedCategory").
setLabels(labels)
lc: org.apache.spark.ml.feature.IndexToString = idxToStr_3d71be25382c
//Step 3b: Build pipeline with desired stages scala> val p = new Pipeline().setStages(Array(stopw,cv,model,lc))
p: org.apache.spark.ml.Pipeline = pipeline_956942e70b3f
//Step 4: Process train data and get predictions
//Step 4a: Execute pipeline with train data scala> val resultsDF = p.fit(train_cat_df).transform(train_cat_df)
resultsDF: org.apache.spark.sql.DataFrame = [words: array<string>, category:
string ... 7 more fields]
//Step 4b: Examine results scala> resultsDF.select("category","predictedCategory").show(3)
+--------+-----------------+
|category|predictedCategory|
+--------+-----------------+
| event| event|
| event| event|
| General| General|
+--------+-----------------+
//Step 4c: Look for prediction mismatches scala> resultsDF.filter("category != predictedCategory").select(
"category","predictedCategory").show(3)
+--------+-----------------+
|category|predictedCategory|
+--------+-----------------+
| General| event|
| General| Hadoop|
|resource| Hadoop|
+--------+-----------------+
//Step 5: Evaluate model using test data
//Step5a: Prepare test data scala> val path = "<Your path> /tweets.json"
path: String = <Your path>/tweets.json
scala> val raw_test_df =
spark.read.text(path).filter($"value".contains(pattern)).
select(split('value, " ") as "words"
raw_test_df: org.apache.spark.sql.DataFrame = [words: array<string>]
scala> val test_cat_df = raw_test_df.withColumn("category",
findCategory(raw_test_df("words")))withColumn("label",idxCategory($"category"))
test_cat_df: org.apache.spark.sql.DataFrame = [words: array<string>, category:
string ... 1 more field]
scala> test_cat_df.groupBy($"category").agg(count("category")).show()
+--------+---------------+
|category|count(category)|
+--------+---------------+
| General| 6|
| event| 11|
+--------+---------------+
//Step 5b: Run predictions on test data scala> val testResultsDF = p.fit(test_cat_df).transform(test_cat_df)
testResultsDF: org.apache.spark.sql.DataFrame = [words: array<string>,
category: string ... 7 more fields]
//Step 5c:: Examine results
scala> testResultsDF.select("category","predictedCategory").show(3)
+--------+-----------------+
|category|predictedCategory|
+--------+-----------------+
| General| event|
| event| General|
| event| General|
+--------+-----------------+
//Step 5d: Look for prediction mismatches scala> testResultsDF.filter("category != predictedCategory").select(
"category","predictedCategory").show()
+--------+-----------------+
|category|predictedCategory|
+--------+-----------------+
| event| General|
| event| General|
+--------+-----------------+
Python:
// Step 1: Initialization
//Step1a: Define a udfs to assign a category // One or more similar words are treated as one category (eg survey, poll)
// If input list contains any of the words in a category list, it is assigned to that category
// "General" is assigned if none of the categories matched
>>> def findCategory(words):
idx = 0; category = ""
categories = [["Python"], ["Hadoop","hadoop"],
["survey","poll"],["event","training", "Meetup", "summit",
"talk", "talks", "Setting","sessions", "workshop"],
["resource","Guide","newsletter", "Blog"]]
while(not category and idx < len(categories)):
if len(set(words).intersection(categories[idx])) > 0:
category = categories[idx][0] #First word in the category list
else:
idx+=1
if not category: #No match found
category = "General"
return category
>>>
//Step 1b: Define udf to convert string category to a numerical label >>> def idxCategory(category):
catgDict = {"General" :1, "event" :2, "Hadoop" :2,
"Python": 4, "resource" : 5}
return catgDict[category]
>>>
//Step 1c: Register UDFs >>> from pyspark.sql.functions import udf
>>> from pyspark.sql.types import StringType, IntegerType
>>> findCategoryUDF = udf(findCategory, StringType())
>>> idxCategoryUDF = udf(idxCategory, IntegerType())
//Step 1d: List categories >>> categories =["General","event","Hadoop","Python","resource"]
//Step 2: Prepare train data
//Step 2a: Extract "text" data and split to words >>> from pyspark.sql.functions import split
>>> path = "../work/tweets_train.txt"
>>> raw_df1 = spark.read.text(path)
>>> raw_df = raw_df1.where("value like '%"text":%'").select(
split("value", " ")).toDF("words")
//Step 2b: Assign a category to each line >>> train_cat_df = raw_df.withColumn("category",\
findCategoryUDF("words")).withColumn(
"label",idxCategoryUDF("category"))
//Step 2c: Examine categories scala> train_cat_df.groupBy("category").count().show()
+--------+---------------+
|category|count(category)|
+--------+---------------+
| General| 146|
|resource| 1|
| Python| 2|
| event| 10|
| Hadoop| 6|
+--------+---------------+
//Step 3: Build pipeline >>> from pyspark.ml import Pipeline
>>> from pyspark.ml.feature import StopWordsRemover, CountVectorizer,
IndexToString
>>> from pyspark.ml.classification import NaiveBayes
>>>
//Step 3a: Define pipeline stages
//Stop words should be removed first >>> stopw = StopWordsRemover(inputCol = "words",
outputCol = "processed_words")
//Terms to term frequency converter >>> cv = CountVectorizer(inputCol = "processed_words",
outputCol = "features")
//Define model >>> model = NaiveBayes(featuresCol="features",
labelCol = "label")
//Numerical prediction label to category converter >>> lc = IndexToString(inputCol = "prediction",
outputCol = "predictedCategory",
labels = categories)
>>>
//Step 3b: Build pipeline with desired stages >>> p = Pipeline(stages = [stopw,cv,model,lc])
>>>
//Step 4: Process train data and get predictions
//Step 4a: Execute pipeline with train data >>> resultsDF = p.fit(train_cat_df).transform(train_cat_df)
//Step 4b: Examine results >>> resultsDF.select("category","predictedCategory").show(3)
+--------+-----------------+
|category|predictedCategory|
+--------+-----------------+
| event| event|
| event| event|
| General| General|
+--------+-----------------+
//Step 4c: Look for prediction mismatches >>> resultsDF.filter("category != predictedCategory").select(
"category","predictedCategory").show(3)
+--------+-----------------+
|category|predictedCategory|
+--------+-----------------+
| Python| Hadoop|
| Python| Hadoop|
| Hadoop| event|
+--------+-----------------+
//Step 5: Evaluate model using test data
//Step5a: Prepare test data >>> path = "<Your path>/tweets.json">>> raw_df1 = spark.read.text(path)
>>> raw_test_df = raw_df1.where("va
ue like '%"text":%'").select(
split("value", " ")).toDF("words")
>>> test_cat_df = raw_test_df.withColumn("category",
findCategoryUDF("words")).withColumn(
"label",idxCategoryUDF("category"))
>>> test_cat_df.groupBy("category").count().show()
+--------+---------------+
|category|count(category)|
+--------+---------------+
| General| 6|
| event| 11|
+--------+---------------+
//Step 5b: Run predictions on test data >>> testResultsDF = p.fit(test_cat_df).transform(test_cat_df)
//Step 5c:: Examine results >>> testResultsDF.select("category","predictedCategory").show(3)
+--------+-----------------+
|category|predictedCategory|
+--------+-----------------+
| General| General|
| event| event|
| event| event|
+--------+-----------------+
//Step 5d: Look for prediction mismatches >>> testResultsDF.filter("category != predictedCategory").select(
"category","predictedCategory").show()
+--------+-----------------+
|category|predictedCategory|
+--------+-----------------+
| event| General|
| event| General|
+--------+-----------------+
完成此步骤后,可以使用该步骤的输出训练一个模型,该模型可以对文本块或文件进行分类。
文本聚类
聚类是一种无监督学习技术。直观地说,聚类将对象分组到不相交的集合中。我们不知道数据中有多少组,也不知道这些组(簇)之间可能有什么共同之处。
文本聚类有多种应用。例如,一个组织实体可能希望根据某种相似性度量将其内部文档组织成相似的簇。相似性或距离的概念是聚类过程的核心。常用的度量方法有 TF-IDF 和余弦相似度。余弦相似度或余弦距离是两个文档的词频向量的余弦乘积。Spark 提供了多种聚类算法,可在文本分析中有效使用。
K-means
也许 K-means 是所有聚类算法中最直观的一种。其思路是根据某些相似性度量方法(如余弦距离或欧几里得距离)将数据点划分为 K 个不同的簇。该算法从 K 个随机的单点簇开始,然后将每个剩余的数据点分配到最近的簇中。接着重新计算簇的中心,并再次遍历数据点。这个过程会反复进行,直到没有重新分配数据点,或达到预定义的迭代次数。
如何确定簇的数量(K)并不是显而易见的。确定初始簇中心也不是显而易见的。有时业务需求可能会决定簇的数量;例如,将所有现有文档划分为 10 个不同的部分。但在大多数实际场景中,我们需要通过反复试验来确定 K。一种方法是逐步增加 K 值并计算簇的质量,例如簇的方差。当 K 值超过某个特定值时,簇的质量不会显著提升,这个 K 值可能就是理想的 K。还有各种其他技术,如肘部法则、赤池信息量准则(AIC)和 贝叶斯信息量准则(BIC)。
同样,使用不同的起始点,直到簇的质量令人满意为止。然后你可能希望使用如轮廓系数等技术来验证结果。然而,这些活动计算量很大。
Spark 提供了来自 MLlib 和 ml 包的 K-means。你可以指定最大迭代次数或收敛容忍度来优化算法的性能。
降维
想象一个拥有许多行和列的大矩阵。在许多矩阵应用中,这个大矩阵可以通过一些行列较少的狭窄矩阵来表示,而这些矩阵仍能代表原始矩阵。然后,处理这个较小的矩阵可能会产生与原始矩阵相似的结果。这种方法在计算上可能更高效。
降维是关于寻找那个小矩阵的。MLlib 支持两种算法:SVD 和 PCA,用于 RowMatrix 类的降维。这两种算法都允许我们指定感兴趣的维度数量。让我们先看一个例子,然后深入探讨其中的理论。
示例 9:降维
Scala:
scala> import scala.util.Random
import scala.util.Random
scala> import org.apache.spark.mllib.linalg.{Vector, Vectors}
import org.apache.spark.mllib.linalg.{Vector, Vectors}
scala> import org.apache.spark.mllib.linalg.distributed.RowMatrix
import org.apache.spark.mllib.linalg.distributed.RowMatrix
//Create a RowMatrix of 6 rows and 5 columns scala> var vlist: Array[Vector] = Array()
vlist: Array[org.apache.spark.mllib.linalg.Vector] = Array()
scala> for (i <- 1 to 6) vlist = vlist :+ Vectors.dense(
Array.fill(5)(Random.nextInt*1.0))
scala> val rows_RDD = sc.parallelize(vlist)
rows_RDD: org.apache.spark.rdd.RDD[org.apache.spark.mllib.linalg.Vector] =
ParallelCollectionRDD[0] at parallelize at <console>:29
scala> val row_matrix = new RowMatrix(rows_RDD)
row_matrix: org.apache.spark.mllib.linalg.distributed.RowMatrix = org.apache.spark.mllib.linalg.distributed.RowMatrix@348a6639
//SVD example for top 3 singular values scala> val SVD_result = row_matrix.computeSVD(3)
SVD_result:
org.apache.spark.mllib.linalg.SingularValueDecomposition[org.apache.spark.mlli
.linalg.distributed.RowMatrix,org.apache.spark.mllib.linalg.Matrix] =
SingularValueDecomposition(null,
[4.933482776606544E9,3.290744495921952E9,2.971558550447048E9],
-0.678871347405378 0.054158900880961904 -0.23905281217240534
0.2278187940802 -0.6393277579229861 0.078663353163388
0.48824560481341733 0.3139021297613471 -0.7800061948839081
-0.4970903877201546 2.366428606359744E-4 -0.3665502780139027
0.041829015676406664 0.6998515759330556 0.4403374382132576 )
scala> SVD_result.s //Show the singular values (strengths)
res1: org.apache.spark.mllib.linalg.Vector =
[4.933482776606544E9,3.290744495921952E9,2.971558550447048E9]
//PCA example to compute top 2 principal components scala> val PCA_result = row_matrix.computePrincipalComponents(2)
PCA_result: org.apache.spark.mllib.linalg.Matrix =
-0.663822435334425 0.24038790854106118
0.3119085619707716 -0.30195355896094916
0.47440026368044447 0.8539858509513869
-0.48429601343640094 0.32543904517535094
-0.0495437635382354 -0.12583837216152594
Python:
在 Spark 2.0.0 版本中,Python 不支持此功能。
奇异值分解
奇异值分解(SVD)是线性代数的核心内容之一,广泛应用于许多实际的建模需求。它提供了一种将矩阵分解成更简单、更小的矩阵的便捷方法。这导致了高维矩阵的低维表示。它帮助我们消除矩阵中不重要的部分,从而生成一个近似的表示。此技术在降维和数据压缩中非常有用。
设 M 为一个大小为 m 行 n 列的矩阵。矩阵的秩是指矩阵中线性无关的行数。如果一行包含至少一个非零元素,并且它不是一行或多行的线性组合,那么该行被认为是独立的。如果我们考虑列而非行(如线性代数中的定义),则会得到相同的秩。
如果一行的元素是两行的和,那么该行不是独立的。随后,通过 SVD 分解,我们得到三个矩阵 U、∑ 和 V,它们满足以下方程:
M = U∑VT
这三个矩阵具有以下特性:
-
U:这是一个具有 m 行和 r 列的列正交规范矩阵。正交规范矩阵意味着每一列都是单位向量,且任意两列的点积为 0。
-
V:这是一个具有 n 行和 r 列的列正交规范矩阵。
-
∑:这是一个 r x r 的对角矩阵,主对角线上的值是按降序排列的非负实数。在对角矩阵中,除了主对角线上的元素外,其它元素都为零。
∑ 矩阵中的主对角线值称为奇异值。它们被视为连接矩阵行和列的基础概念或成分。它们的大小表示对应成分的强度。例如,假设前面提到的矩阵包含六个读者对五本书的评分。SVD 可以将其分解为三个矩阵:∑ 包含代表基础主题强度的奇异值;U 连接人和概念;V 连接概念和书籍。
在大矩阵中,我们可以将较小的奇异值替换为零,从而减少剩余两个矩阵中相应行的维度。注意,如果我们重新计算右边的矩阵乘积并将其与左边的原矩阵进行比较,它们将几乎相同。我们可以使用这种方法来保留所需的维度数。
主成分分析
主成分分析 (PCA) 是一种将 n 维数据点投影到一个较小(维度更少)的子空间的技术,同时尽量减少信息的丢失。高维空间中的一组数据点会找到使这些数据点排列最佳的方向。换句话说,我们需要找到一种旋转方式,使得第一个坐标具有可能的最大方差,每个后续坐标依次具有最大的方差。这个思路是将数据集作为一个矩阵 M,并找到 MMT 的特征向量。
如果* A 是一个方阵, e 是一个列矩阵,行数与 A 相同,且 λ 是一个常数,使得 Me = λe ,那么 e 被称为 M 的特征向量, λ 被称为 M *的特征值。在 n 维平面上,特征向量是方向,特征值是沿该方向的方差度量。我们可以丢弃特征值较低的维度,从而找到一个较小的子空间而不会丢失信息。
总结
在本章中,我们探讨了非结构化数据的来源以及分析非结构化数据背后的动机。我们解释了在预处理非结构化数据时需要的各种技术,以及 Spark 如何提供大部分这些工具。我们还介绍了 Spark 支持的一些可以用于文本分析的算法。
在下一章中,我们将介绍不同类型的可视化技术,这些技术在数据分析生命周期的不同阶段提供了深刻的见解。
参考文献:
以下是参考文献:
-
nlp.stanford.edu/IR-book/html/htmledition/naive-bayes-text-classification-1.html
-
spark.apache.org/docs/latest/mllib-dimensionality-reduction.html
计数向量化:
n-gram 建模:
第九章:可视化大数据
正确的数据可视化在过去解决了许多商业问题,而无需过多依赖统计学或机器学习。即使在今天,随着技术进步、应用统计学和机器学习的发展,合适的视觉呈现依然是商业用户获取信息或分析结果的最终交付物。传递正确的信息、以正确的格式展现,这是数据科学家所追求的,而一个有效的可视化比百万个词语还要有价值。此外,以易于商业用户理解的方式呈现模型和生成的洞察至关重要。尽管如此,以可视化方式探索大数据非常繁琐且具有挑战性。由于 Spark 是为大数据处理设计的,它也支持大数据的可视化。为了这个目的,基于 Spark 构建了许多工具和技术。
前几章概述了如何对结构化和非结构化数据建模并从中生成洞察。在本章中,我们将从两个广泛的角度来看待数据可视化——一个是数据科学家的角度,数据可视化是有效探索和理解数据的基本需求;另一个是商业用户的角度,视觉呈现是交付给商业用户的最终成果,必须易于理解。我们将探索多种数据可视化工具,如IPythonNotebook和Zeppelin,这些工具可以在 Apache Spark 上使用。
本章的前提是,你应该对 SQL 以及 Python、Scala 或其他类似框架的编程有基本的了解。 本章涵盖的主题如下:
-
为什么要可视化数据?
-
数据工程师的角度
-
数据科学家的角度
-
商业用户的角度
-
-
数据可视化工具
-
IPython notebook
-
Apache Zeppelin
-
第三方工具
-
-
数据可视化技巧
-
总结和可视化
-
子集和可视化
-
抽样和可视化
-
建模和可视化
-
为什么要可视化数据?
数据可视化是以可视化的形式呈现数据,以帮助人们理解数据背后的模式和趋势。地理地图、十七世纪的条形图和折线图是早期数据可视化的一些例子。Excel 可能是我们最熟悉的数据可视化工具,许多人已经使用过。所有的数据分析工具都配备了复杂的、互动的数据可视化仪表盘。然而,近年来大数据、流式数据和实时分析的激增推动了这些工具的边界,似乎已经达到了极限。目标是使可视化看起来简单、准确且相关,同时隐藏所有复杂性。根据商业需求,任何可视化解决方案理想情况下应该具备以下特点:
-
交互性
-
可重复性
-
控制细节
除此之外,如果解决方案允许用户在可视化或报告上进行协作并相互共享,那么这将构成一个端到端的可视化解决方案。
特别是大数据可视化面临着自身的挑战,因为我们可能会遇到数据比屏幕上的像素还多的情况。处理大数据通常需要大量的内存和 CPU 处理,且可能存在较长的延迟。如果再加入实时或流数据,这个问题将变得更加复杂。Apache Spark 从一开始就被设计用来通过并行化 CPU 和内存使用来解决这种延迟。在探索可视化和处理大数据的工具和技术之前,我们首先要了解数据工程师、数据科学家和业务用户的可视化需求。
数据工程师的视角
数据工程师在几乎所有数据驱动的需求中都发挥着至关重要的作用:从不同的数据源获取数据,整合数据,清洗和预处理数据,分析数据,最终通过可视化和仪表板进行报告。其活动可以大致总结如下:
-
可视化来自不同来源的数据,以便能够整合并合并它们,形成一个单一的数据矩阵
-
可视化并发现数据中的各种异常,如缺失值、异常值等(这可能发生在抓取、数据源获取、ETL 等过程中),并进行修复
-
向数据科学家提供有关数据集的属性和特征的建议
-
探索多种可能的方式来可视化数据,并根据业务需求最终确定那些更具信息性和直观性的方式
请注意,数据工程师不仅在数据源获取和准备过程中发挥关键作用,还负责决定最适合业务用户的可视化输出。他们通常与业务部门密切合作,以便对业务需求和当前具体问题有非常清晰的理解。
数据科学家的视角
数据科学家在可视化数据方面的需求与数据工程师不同。请注意,在某些业务中,可能有专业人员同时承担数据工程师和数据科学家的双重角色。
数据科学家需要可视化数据,以便在进行统计分析时做出正确的决策,并确保分析项目的顺利执行。他们希望以多种方式切片和切块数据,以发现隐藏的洞察。让我们看看数据科学家在可视化数据时可能需要的一些示例需求:
-
查看各个变量的数据分布
-
可视化数据中的异常值
-
可视化数据集中所有变量的缺失数据百分比
-
绘制相关性矩阵以查找相关的变量
-
绘制回归后的残差行为
-
在数据清洗或转换活动后,重新绘制变量图,并观察其表现
请注意,刚才提到的一些内容与数据工程师的情况非常相似。然而,数据科学家可能在这些分析背后有更科学/统计的意图。例如,数据科学家可能会从不同的角度看待一个离群值并进行统计处理,而数据工程师则可能考虑导致这一现象的多种可能选项。
业务用户的视角
业务用户的视角与数据工程师或数据科学家的视角完全不同。业务用户通常是信息的消费者!他们希望从数据中提取更多的信息,为此,正确的可视化工具至关重要。此外,如今大多数业务问题都更加复杂且具因果关系。传统的报告已经不再足够。我们来看一些业务用户希望从报告、可视化和仪表板中提取的示例查询:
-
在某个地区,谁是高价值客户?
-
这些客户的共同特征是什么?
-
预测一个新客户是否会是高价值客户
-
在哪个媒体上做广告能获得最大的投资回报率?
-
如果我不在报纸上做广告会怎样?
-
影响客户购买行为的因素有哪些?
数据可视化工具
在众多可视化选项中,选择合适的可视化工具取决于特定的需求。同样,选择可视化工具也取决于目标受众和业务需求。
数据科学家或数据工程师通常会偏好一个更为互动的控制台,用于快速且粗略的分析。他们使用的可视化工具通常不面向业务用户。数据科学家或数据工程师倾向于从各个角度解构数据,以获得更有意义的洞察。因此,他们通常会更喜欢支持这些活动的笔记本类型界面。笔记本是一个互动的计算环境,用户可以在其中结合代码块并绘制数据进行探索。像IPython/Jupyter或DataBricks等笔记本就是可用的选项之一。
业务用户更喜欢直观且信息丰富的可视化,这样他们可以相互分享或用来生成报告。他们期望通过可视化得到最终结果。市场上有成百上千种工具,包括一些流行工具,如Tableau,企业都在使用;但通常,开发人员必须为一些独特的需求定制特定类型,并通过 Web 应用程序展示它们。微软的PowerBI和开源解决方案如Zeppelin就是一些例子。
IPython 笔记本
基于 Spark 的PySpark API 之上的 IPython/Jupyter 笔记本是数据科学家探索和可视化数据的绝佳组合。笔记本内部启动了一个新的 PySpark 内核实例。还有其他内核可用;例如,Apache 的Toree内核可以用来支持 Scala。
对于许多数据科学家来说,这是默认选择,因为它能够将文本、代码、公式和图形集成在一个 JSON 文档文件中。IPython 笔记本支持matplotlib
,它是一个可以生成生产质量视觉效果的二维可视化库。生成图表、直方图、散点图、图形等变得既简单又容易。它还支持seaborn
库,实际上这是建立在 matplotlib 基础上的,但它易于使用,因为它提供了更高级的抽象,隐藏了底层的复杂性。
Apache Zeppelin
Apache Zeppelin 建立在 JVM 之上,并与 Apache Spark 良好集成。它是一个基于浏览器或前端的开源工具,拥有自己的笔记本。它支持 Scala、Python、R、SQL 及其他图形模块,作为一种可视化解决方案,不仅为业务用户,也为数据科学家服务。在接下来的可视化技术部分中,我们将看看 Zeppelin 如何支持 Apache Spark 代码来生成有趣的可视化效果。你需要下载 Zeppelin(zeppelin.apache.org/
)以尝试这些示例。
第三方工具
有许多产品支持 Apache Spark 作为底层数据处理引擎,并且是为了适应组织的大数据生态系统而构建的。它们利用 Spark 的处理能力,提供支持各种交互式视觉效果的可视化界面,并且支持协作。Tableau 就是一个利用 Spark 的工具示例。
数据可视化技术
数据可视化是数据分析生命周期每个阶段的核心。它对探索性分析和结果传达尤为重要。在这两种情况下,目标都是将数据转换成对人类处理高效的格式。将转换委托给客户端库的方法无法扩展到大数据集。转换必须在服务器端进行,只将相关数据发送到客户端进行渲染。Apache Spark 开箱即用地提供了大多数常见的转换。让我们仔细看看这些转换。
总结和可视化
总结和可视化 是许多商业智能(BI)工具使用的一种技术。由于总结无论底层数据集的大小如何,都会生成简明的数据集,因此图表看起来足够简单并且易于渲染。有多种方法可以总结数据,例如聚合、透视等。如果渲染工具支持交互性并且具备下钻功能,用户可以从完整数据中探索感兴趣的子集。我们将展示如何通过 Zeppelin 笔记本快速、互动地进行总结。
以下图片展示了 Zeppelin 笔记本,包含源代码和分组条形图。数据集包含 24 条观测数据,记录了两个产品 P1 和 P2 在 12 个月中的销售信息。第一个单元格包含用于读取文本文件并将数据注册为临时表的代码。此单元格使用默认的 Spark 解释器 Scala。第二个单元格使用 SQL 解释器,支持开箱即用的可视化选项。你可以通过点击右侧图标切换图表类型。请注意,Scala、Python 或 R 解释器的可视化效果是相似的。
汇总示例如下:
-
用于读取数据并注册为 SQL 视图的源代码:
Scala(默认):
https://github.com/OpenDocCN/freelearn-ds-pt5-zh/raw/master/docs/spk-ds/img/image_09_001.jpg
PySpark:
https://github.com/OpenDocCN/freelearn-ds-pt5-zh/raw/master/docs/spk-ds/img/image_09_002.jpg
R:
https://github.com/OpenDocCN/freelearn-ds-pt5-zh/raw/master/docs/spk-ds/img/image_09_003.jpg
这三者都在读取数据文件并注册为临时 SQL 视图。请注意,前面的三个脚本中存在一些细微的差异。例如,我们需要为 R 移除表头行并设置列名。下一步是生成可视化,它是通过
%sql
解释器工作的。下图显示了生成每个产品季度销售额的脚本。它还显示了现成的图表类型,以及设置和选择。做出选择后,你可以折叠设置。你甚至可以使用 Zeppelin 内置的动态表单,例如在运行时接受产品输入。第二张图显示了实际输出。 -
用于生成两个产品季度销售额的脚本:https://github.com/OpenDocCN/freelearn-ds-pt5-zh/raw/master/docs/spk-ds/img/image_09_004.jpg
-
产生的输出:https://github.com/OpenDocCN/freelearn-ds-pt5-zh/raw/master/docs/spk-ds/img/image_09_005.jpg
我们在前面的示例中已经看到了 Zeppelin 的内置可视化。但我们也可以使用其他绘图库。我们的下一个示例使用 PySpark 解释器与 matplotlib 在 Zeppelin 中绘制直方图。此示例代码使用 RDD 的直方图函数计算桶间隔和桶计数,并仅将这些汇总数据传送到驱动节点。频率作为权重绘制桶,以提供与正常直方图相同的视觉效果,但数据传输非常低。
直方图示例如下:
https://github.com/OpenDocCN/freelearn-ds-pt5-zh/raw/master/docs/spk-ds/img/image_09_006.jpg
这是生成的输出(它可能作为一个单独的窗口弹出):
https://github.com/OpenDocCN/freelearn-ds-pt5-zh/raw/master/docs/spk-ds/img/image_09_007.jpg
在前面的直方图准备示例中,请注意,桶计数可以通过内置的动态表单支持进行参数化。
子集化和可视化
有时,我们可能拥有一个大型数据集,但只对其中的一部分感兴趣。“分而治之”是一种方法,我们一次探索数据的一个小部分。Spark 允许使用类似 SQL 的过滤器和聚合对行列数据集以及图形数据进行数据子集化。让我们首先执行 SQL 子集化,然后是 GraphX 示例。
以下示例使用 Zeppelin 提供的银行数据,并提取与管理者相关的几个相关数据列。它使用google 可视化库
绘制气泡图。数据是使用 PySpark 读取的。数据子集化和可视化是通过 R 完成的。请注意,我们可以选择任何解释器来执行这些任务,这里选择的是随意的。
使用 SQL 进行数据子集化的示例如下:
-
读取数据并注册 SQL 视图:https://github.com/OpenDocCN/freelearn-ds-pt5-zh/raw/master/docs/spk-ds/img/image_09_008.jpg
-
子集化管理者数据并显示气泡图:https://github.com/OpenDocCN/freelearn-ds-pt5-zh/raw/master/docs/spk-ds/img/image_09_009.jpg
下一个示例演示了一些使用 斯坦福网络分析项目(SNAP)提供的数据的 GraphX 处理。该脚本提取了一个包含给定节点集的子图。在这里,每个节点代表一个 Facebook ID,一条边代表两个节点(或人)之间的连接。此外,脚本还识别了给定节点(ID:144)的直接连接。这些是一级节点。然后,它识别这些一级节点的直接连接,这些形成了给定节点的二级节点。即使一个二级联系人可能与多个一级联系人连接,它也只显示一次,从而形成没有交叉边的连接树。由于连接树可能包含太多节点,脚本将一级和二级连接限制为最多三个连接,从而在给定的根节点下仅显示 12 个节点(一个根节点 + 三个一级节点 + 每个二级节点的三个连接)。
Scala
//Subset and visualize
//GraphX subset example
//Datasource: http://snap.stanford.edu/data/egonets-Facebook.html
import org.apache.spark.graphx._
import org.apache.spark.graphx.util.GraphGenerators
//Load edge file and create base graph
val base_dir = "../data/facebook"
val graph = GraphLoader.edgeListFile(sc,base_dir + "/0.edges")
//Explore subgraph of a given set of nodes
val circle = "155 99 327 140 116 147 144 150 270".split("\t").map(
x=> x.toInt)
val subgraph = graph.subgraph(vpred = (id,name)
=> circle.contains(id))
println("Edges: " + subgraph.edges.count +
" Vertices: " + subgraph.vertices.count)
//Create a two level contact tree for a given node
//Step1: Get all edges for a given source id
val subgraph_level1 = graph.subgraph(epred= (ed) =>
ed.srcId == 144)
//Step2: Extract Level 1 contacts
import scala.collection.mutable.ArrayBuffer
val lvl1_nodes : ArrayBuffer[Long] = ArrayBuffer()
subgraph_level1.edges.collect().foreach(x=> lvl1_nodes+= x.dstId)
//Step3: Extract Level 2 contacts, 3 each for 3 lvl1_nodes
import scala.collection.mutable.Map
val linkMap:Map[Long, ArrayBuffer[Long]] = Map() //parent,[Child]
val lvl2_nodes : ArrayBuffer[Long] = ArrayBuffer() //1D Array
var n : ArrayBuffer[Long] = ArrayBuffer()
for (i <- lvl1_nodes.take(3)) { //Limit to 3
n = ArrayBuffer()
graph.subgraph(epred = (ed) => ed.srcId == i &&
!(lvl2_nodes contains ed.dstId)).edges.collect().
foreach(x=> n+=x.dstId)
lvl2_nodes++=n.take(3) //Append to 1D array. Limit to 3
linkMap(i) = n.take(3) //Assign child nodes to its parent
}
//Print output and examine the nodes
println("Level1 nodes :" + lvl1_nodes)
println("Level2 nodes :" + lvl2_nodes)
println("Link map :" + linkMap)
//Copy headNode to access from another cell
z.put("headNode",144)
//Make a DataFrame out of lvl2_nodes and register as a view
val nodeDF = sc.parallelize(linkMap.toSeq).toDF("parentNode","childNodes")
nodeDF.createOrReplaceTempView("node_tbl")
注意
请注意 z.put
和 z.get
的使用。这是 Zeppelin 中用于交换单元格/解释器之间数据的一种机制。
现在我们已经创建了一个包含一级联系人及其直接联系人的数据框架,我们准备好绘制树形图了。以下脚本使用了图形可视化库 igraph 和 Spark R。
提取节点和边。绘制树形图:
https://github.com/OpenDocCN/freelearn-ds-pt5-zh/raw/master/docs/spk-ds/img/image_09_010.jpg
上述脚本从节点表中获取父节点,这些父节点是第 2 级节点的父节点,也是与给定头节点的直接连接。创建头节点与第 1 级节点的有序对,并将其分配给 edges1
。接下来的步骤是展开第 2 级节点数组,将每个数组元素形成一行。由此获得的数据框被转置并粘贴,形成边对。由于粘贴操作将数据转换为字符串,因此需要重新转换为数字。这些就是第 2 级边。第 1 级和第 2 级边被合并,形成一个边的单一列表。这些边接着被用来形成图形,如下所示。请注意,headNode
中的模糊值为 144,虽然在下图中不可见:
https://github.com/OpenDocCN/freelearn-ds-pt5-zh/raw/master/docs/spk-ds/img/image_09_011.jpg
给定节点的连接树
抽样与可视化
抽样和可视化技术已被统计学家使用了很长时间。通过抽样技术,我们从数据集中提取一部分数据并进行处理。我们将展示 Spark 如何支持不同的抽样技术,如随机抽样、分层抽样和sampleByKey等。以下示例是在 Jupyter notebook 中创建的,使用了 PySpark 核心和 seaborn
库。数据文件是 Zeppelin 提供的银行数据集。第一个图展示了每个教育类别的余额,颜色表示婚姻状况。
读取数据并随机抽取 5% 的样本:
https://github.com/OpenDocCN/freelearn-ds-pt5-zh/raw/master/docs/spk-ds/img/image_09_012.jpg
使用 stripplot
渲染数据:
https://github.com/OpenDocCN/freelearn-ds-pt5-zh/raw/master/docs/spk-ds/img/image_09_013.jpg
上述示例展示了随机抽样的可用数据,这比完全绘制整体数据要好得多。但是,如果感兴趣的分类变量(在本例中是 education
)的级别过多,那么这个图表就会变得难以阅读。例如,如果我们想绘制工作类别的余额而不是 education
,将会有太多的条形,使得图像看起来凌乱不堪。相反,我们可以只抽取所需类别级别的样本,然后再进行数据分析。请注意,这与子集选择不同,因为在正常的 SQL WHERE
子集选择中我们无法指定样本比例。我们需要使用 sampleByKey
来实现这一点,如下所示。以下示例只取了两种工作类别,并且有特定的抽样比例:
https://github.com/OpenDocCN/freelearn-ds-pt5-zh/raw/master/docs/spk-ds/img/image_09_014.jpg
分层抽样
建模与可视化
使用 Spark 的MLLib和ML模块可以进行建模与可视化。Spark 的统一编程模型和多样化的编程接口使得将这些技术结合到一个环境中,从数据中获取洞察成为可能。我们已经在前几章中涵盖了大多数建模技术。然而,以下是一些供参考的示例:
-
聚类:K-means、Gaussian 混合模型
-
分类与回归:线性模型、决策树、朴素贝叶斯、支持向量机(SVM)
-
降维:奇异值分解,主成分分析
-
协同过滤
-
统计测试:相关性,假设检验
以下示例选自第七章,使用 SparkR 扩展 Spark,该示例试图使用朴素贝叶斯模型预测学生的合格或不合格结果。其思路是利用 Zeppelin 提供的开箱即用功能,检查模型的行为。因此,我们加载数据,进行数据准备,构建模型并运行预测。然后,我们将预测结果注册为 SQL 视图,以便利用内置的可视化功能:
//Model visualization example using zeppelin visualization
Prepare Model and predictions
https://github.com/OpenDocCN/freelearn-ds-pt5-zh/raw/master/docs/spk-ds/img/image_09_015.jpg
下一步是编写所需的 SQL 查询并定义适当的设置。请注意 SQL 中的 UNION 操作符的使用以及匹配列的定义方式。
定义 SQL 以查看模型表现:
https://github.com/OpenDocCN/freelearn-ds-pt5-zh/raw/master/docs/spk-ds/img/image_09_016.jpg
以下图片帮助我们理解模型预测与实际数据的偏差。这样的可视化对于获取业务用户的反馈非常有用,因为它们不需要任何数据科学的先验知识就能理解:
https://github.com/OpenDocCN/freelearn-ds-pt5-zh/raw/master/docs/spk-ds/img/image_09_017.jpg
可视化模型表现
我们通常使用误差指标来评估统计模型,但通过图形化展示而不是查看数字使其更加直观,因为通常理解图表比理解表格中的数字更容易。例如,前面的可视化可以让非数据科学领域的人也容易理解。
总结
在本章中,我们探讨了在大数据环境中 Spark 支持的最常用可视化工具和技术。我们通过代码片段解释了一些技术,以便更好地理解数据分析生命周期各个阶段的可视化需求。我们还展示了如何通过适当的可视化技术解决大数据的挑战,以满足业务需求。
下一章将是我们迄今为止解释的所有概念的总结。我们将通过一个示例数据集走完完整的数据分析生命周期。
参考文献
-
21 种必备数据可视化工具:
www.kdnuggets.com/2015/05/21-essential-data-visualization-tools.html
-
Apache Zeppelin notebook 主页:
zeppelin.apache.org/
-
Jupyter notebook 主页:
jupyter.org/
-
使用 IPython Notebook 与 Apache Spark:
hortonworks.com/hadoop-tutorial/using-ipython-notebook-with-apache-spark/
-
Apache Toree,支持应用程序与 Spark 集群之间的交互式工作负载。可以与 jupyter 一起使用来运行 Scala 代码:
toree.incubator.apache.org/
-
使用 R 的 GoogleVis 包:
cran.rproject.org/web/packages/googleVis/vignettes/googleVis_examples.html
-
GraphX 编程指南:
spark.apache.org/docs/latest/graphx-programming-guide.html
-
使用 R 的 igraph 包进行病毒传播分析:
www.r-bloggers.com/going-viral-with-rs-igraph-package/
-
使用分类数据绘图:
stanford.edu/~mwaskom/software/seaborn/tutorial/categorical.html#categorical-tutorial
数据来源引用
银行数据来源(引用)
-
[Moro et al., 2011] S. Moro, R. Laureano 和 P. Cortez. 使用数据挖掘进行银行直销营销:CRISP-DM 方法的应用
-
见 P. Novais 等人(编辑),《欧洲仿真与建模会议论文集 - ESM’2011》,第 117-121 页,葡萄牙吉马良斯,2011 年 10 月,EUROSIS
-
可通过 [pdf]
hdl.handle.net/1822/14838
获取 -
[bib] http://www3.dsi.uminho.pt/pcortez/bib/2011-esm-1.txt
Facebook 数据来源(引用)
- J. McAuley 和 J. Leskovec. 学习在自我网络中发现社交圈。NIPS, 2012.
第十章:整合所有内容
大数据分析正在革新企业运营方式,并为许多前所未有的机会铺平道路。几乎每个企业、个人研究人员或调查记者都有大量数据需要处理。我们需要一种简洁的方法,从原始数据出发,根据当前的问题得出有意义的洞察。
在之前的章节中,我们已经讨论了使用 Apache Spark 进行数据科学的各个方面。我们从讨论大数据分析需求以及 Apache Spark 如何适应这些需求开始。逐步地,我们探讨了 Spark 编程模型、RDD 和 DataFrame 抽象,并学习了 Spark 数据集如何通过连续应用的流式处理方面实现统一数据访问。接着,我们覆盖了使用 Apache Spark 进行数据分析生命周期的全貌,随后是机器学习的内容。我们学习了在 Spark 上进行结构化和非结构化数据分析,并探索了面向数据工程师、科学家以及业务用户的可视化方面。
所有之前讨论的章节帮助我们理解了每个章节中的一个简洁方面。现在我们已经具备了穿越整个数据科学生命周期的能力。在这一章节中,我们将通过一个端到端的案例研究,应用我们迄今为止学到的所有内容。我们不会引入任何新的概念;这将帮助我们应用已获得的知识并加深理解。然而,我们会重复一些概念,避免过多细节,使这一章节能够自成一体。本章所覆盖的主题大致与数据分析生命周期中的步骤相同:
-
快速回顾
-
引入案例研究
-
构建业务问题
-
数据获取与数据清洗
-
提出假设
-
数据探索
-
数据准备
-
模型构建
-
数据可视化
-
向业务用户传达结果
-
总结
快速回顾
我们已经在不同章节中详细讨论了典型数据科学项目中的各个步骤。让我们快速回顾一下我们已经覆盖的内容,并简要提及一些重要方面。以下图表展示了这些步骤的高层次概述:
https://github.com/OpenDocCN/freelearn-ds-pt5-zh/raw/master/docs/spk-ds/img/image_10_001.jpg
在前面的图示中,我们尝试从更高层次解释数据科学项目中的步骤,这些步骤通常适用于许多数据科学任务。每个阶段实际上都包含更多的子步骤,但可能因项目不同而有所差异。
对数据科学家来说,一开始找到最佳方法和步骤是非常困难的。通常,数据科学项目没有像软件开发生命周期(SDLC)那样明确的生命周期。数据科学项目通常会因为周期性步骤而导致交付延迟,而且这些步骤是反复迭代的。此外,跨团队的循环依赖也增加了复杂性并导致执行延迟。然而,在处理大数据分析项目时,数据科学家遵循一个明确的数据科学工作流程,无论业务案例如何,都显得尤为重要且有利。这不仅有助于组织执行,还能帮助我们保持专注于目标,因为数据科学项目在大多数情况下天生是敏捷的。同时,建议你为任何项目规划一些关于数据、领域和算法的研究。
在这一章中,我们可能无法将所有细节步骤放在一个流程中,但我们会涉及到一些重要的部分,为你提供一个概览。我们将尝试看一些之前章节未涉及的编码示例。
引入案例研究
在本章中,我们将探索奥斯卡奖的受众人口统计信息。你可以从 GitHub 仓库下载数据:www.crowdflower.com/wp-content/uploads/2016/03/Oscars-demographics-DFE.csv
。
该数据集基于www.crowdflower.com/data-for-everyone
提供的数据。它包含了种族、出生地和年龄等人口统计信息。数据行大约有 400 条,可以在简单的家庭计算机上轻松处理,因此你可以在 Spark 上执行一个概念验证(POC)来进行数据科学项目的尝试。
只需下载文件并检查数据。数据看起来可能没问题,但当你仔细查看时,你会发现它并不“干净”。例如,出生日期这一列没有统一的格式。有些年份是两位数字格式,而有些则是四位数字格式。出生地列在美国境内的地点没有包含国家信息。
同样,你会注意到数据看起来有偏差,来自美国的“白人”种族人数较多。但你可能会觉得,趋势在近几年发生了变化。到目前为止,你还没有使用任何工具或技术,只是对数据进行了快速浏览。在数据科学的实际工作中,这种看似微不足道的活动可能在整个生命周期中非常有帮助。你能够逐步对手头的数据形成感觉,并同时对数据提出假设。这将带你进入工作流程的第一步。
商业问题
如前所述,任何数据科学项目中最重要的方面是手头的问题。清楚地理解*我们要解决什么问题?*对项目的成功至关重要。它还决定了什么数据被视为相关,什么数据不相关。例如,在当前的案例研究中,如果我们要关注的是人口统计信息,那么电影名称和人物名称就是不相关的。有时,手头没有具体问题!*那怎么办?*即使没有具体问题,商业可能仍然有一些目标,或者数据科学家和领域专家可以合作,找到需要解决的商业领域。为了理解商业、职能、问题陈述或数据,数据科学家首先会进行“提问”。这不仅有助于定义工作流程,还能帮助寻找正确的数据来源。
举个例子,如果商业关注点是人口统计信息,那么可以定义一个正式的商业问题陈述:
种族和原籍地对奥斯卡奖获得者的影响是什么?
在现实场景中,这一步骤不会如此简单。提出正确的问题是数据科学家、战略团队、领域专家和项目负责人共同的责任。因为如果不服务于目的,整个过程都是徒劳的,所以数据科学家必须咨询所有相关方,并尽可能从他们那里获取信息。然而,他们最终可能会得到一些宝贵的见解或“直觉”。所有这些信息共同构成了初步假设的核心,并帮助数据科学家理解他们应该寻找什么。
在没有具体问题的情况下,商业试图找出答案的情形更为有趣,但执行起来可能更复杂!
数据采集与数据清洗
数据采集是逻辑上的下一步。它可能仅仅是从一个电子表格中选择数据,也可能是一个复杂的、持续几个月的项目。数据科学家必须尽可能多地收集相关数据。这里的“相关”是关键词。记住,更多相关的数据胜过聪明的算法。
我们已经介绍了如何从异构数据源中获取数据并将其整合成一个单一的数据矩阵,因此这里不再重复这些基础知识。相反,我们从一个单一来源获取数据,并提取其中的一个子集。
现在是时候查看数据并开始清洗它了。本章中呈现的脚本通常比之前的示例更长,但仍然不能算作生产级别的质量。实际工作中需要更多的异常检查和性能调优:
Scala
//Load tab delimited file
scala> val fp = "<YourPath>/Oscars.txt"
scala> val init_data = spark.read.options(Map("header"->"true", "sep" -> "\t","inferSchema"->"true")).csv(fp)
//Select columns of interest and ignore the rest
>>> val awards = init_data.select("birthplace", "date_of_birth",
"race_ethnicity","year_of_award","award").toDF(
"birthplace","date_of_birth","race","award_year","award")
awards: org.apache.spark.sql.DataFrame = [birthplace: string, date_of_birth: string ... 3 more fields]
//register temporary view of this dataset
scala> awards.createOrReplaceTempView("awards")
//Explore data
>>> awards.select("award").distinct().show(10,false) //False => do not truncate
+-----------------------+
|award |
+-----------------------+
|Best Supporting Actress|
|Best Director |
|Best Actress |
|Best Actor |
|Best Supporting Actor |
+-----------------------+
//Check DOB quality. Note that length varies based on month name
scala> spark.sql("SELECT distinct(length(date_of_birth)) FROM awards ").show()
+---------------------+
|length(date_of_birth)|
+---------------------+
| 15|
| 9|
| 4|
| 8|
| 10|
| 11|
+---------------------+
//Look at the value with unexpected length 4 Why cant we show values for each of the length type ?
scala> spark.sql("SELECT date_of_birth FROM awards WHERE length(date_of_birth) = 4").show()
+-------------+
|date_of_birth|
+-------------+
| 1972|
+-------------+
//This is an invalid date. We can either drop this record or give some meaningful value like 01-01-1972
Python
//Load tab delimited file
>>> init_data = spark.read.csv("<YOURPATH>/Oscars.txt",sep="\t",header=True)
//Select columns of interest and ignore the rest
>>> awards = init_data.select("birthplace", "date_of_birth",
"race_ethnicity","year_of_award","award").toDF(
"birthplace","date_of_birth","race","award_year","award")
//register temporary view of this dataset
>>> awards.createOrReplaceTempView("awards")
scala>
//Explore data
>>> awards.select("award").distinct().show(10,False) //False => do not truncate
+-----------------------+
|award |
+-----------------------+
|Best Supporting Actress|
|Best Director |
|Best Actress |
|Best Actor |
|Best Supporting Actor |
+-----------------------+
//Check DOB quality
>>> spark.sql("SELECT distinct(length(date_of_birth)) FROM awards ").show()
+---------------------+
|length(date_of_birth)|
+---------------------+
| 15|
| 9|
| 4|
| 8|
| 10|
| 11|
+---------------------+
//Look at the value with unexpected length 4\. Note that length varies based on month name
>>> spark.sql("SELECT date_of_birth FROM awards WHERE length(date_of_birth) = 4").show()
+-------------+
|date_of_birth|
+-------------+
| 1972|
+-------------+
//This is an invalid date. We can either drop this record or give some meaningful value like 01-01-1972
Most of the datasets contain a date field and unless they come from a single, controlled data source, it is highly likely that they will differ in their formats and are almost always a candidate for cleaning.
对于手头的数据集,你可能也注意到 date_of_birth
和 birthplace
需要大量清理。以下代码展示了两个 用户定义函数(UDFs),分别清理 date_of_birth
和 birthplace
。这些 UDF 每次处理单个数据元素,它们只是普通的 Scala/Python 函数。为了能够在 SQL 语句中使用,这些用户定义的函数应该被注册。最后一步是创建一个清理后的数据框,参与进一步的分析。
注意下面的清理 birthplace
的逻辑。它是一个比较简单的逻辑,因为我们假设任何以两个字符结尾的字符串都是美国的一个州。我们需要将其与有效的州缩写列表进行比较。同样,假设两位数的年份总是来自二十世纪也是一个容易出错的假设。根据实际情况,数据科学家/数据工程师需要决定是否保留更多行,或者只包括质量更高的数据。所有这些决策应该清晰地记录下来,供后续参考:
Scala:
//UDF to clean date
//This function takes 2 digit year and makes it 4 digit
// Any exception returns an empty string
scala> def fncleanDate(s:String) : String = {
var cleanedDate = ""
val dateArray: Array[String] = s.split("-")
try{ //Adjust year
var yr = dateArray(2).toInt
if (yr < 100) {yr = yr + 1900 } //make it 4 digit
cleanedDate = "%02d-%s-%04d".format(dateArray(0).toInt,
dateArray(1),yr)
} catch { case e: Exception => None }
cleanedDate }
fncleanDate: (s: String)String
Python:
//This function takes 2 digit year and makes it 4 digit
// Any exception returns an empty string
>>> def fncleanDate(s):
cleanedDate = ""
dateArray = s.split("-")
try: //Adjust year
yr = int(dateArray[2])
if (yr < 100):
yr = yr + 1900 //make it 4 digit
cleanedDate = "{0}-{1}-{2}".format(int(dateArray[0]),
dateArray[1],yr)
except :
None
return cleanedDate
清理日期的 UDF 接受一个带有连字符的日期字符串并将其拆分。如果最后一个组件(即年份)是两位数,那么假设它是二十世纪的日期,并加上 1900 将其转换为四位数格式。
以下 UDF 会将国家设置为美国(USA),如果国家字符串是“纽约市”或者最后一个组件是两个字符长,这时假设它是美国的一个州:
//UDF to clean birthplace
// Data explorartion showed that
// A. Country is omitted for USA
// B. New York City does not have State code as well
//This function appends country as USA if
// A. the string contains New York City (OR)
// B. if the last component is of length 2 (eg CA, MA)
scala> def fncleanBirthplace(s: String) : String = {
var cleanedBirthplace = ""
var strArray : Array[String] = s.split(" ")
if (s == "New York City")
strArray = strArray ++ Array ("USA")
//Append country if last element length is 2
else if (strArray(strArray.length-1).length == 2)
strArray = strArray ++ Array("USA")
cleanedBirthplace = strArray.mkString(" ")
cleanedBirthplace }
Python:
>>> def fncleanBirthplace(s):
cleanedBirthplace = ""
strArray = s.split(" ")
if (s == "New York City"):
strArray += ["USA"] //Append USA
//Append country if last element length is 2
elif (len(strArray[len(strArray)-1]) == 2):
strArray += ["USA"]
cleanedBirthplace = " ".join(strArray)
return cleanedBirthplace
如果想通过 SELECT 字符串访问 UDF,应该注册 UDF:
Scala:
//Register UDFs
scala> spark.udf.register("fncleanDate",fncleanDate(_:String))
res10: org.apache.spark.sql.expressions.UserDefinedFunction = UserDefinedFunction(<function1>,StringType,Some(List(StringType)))
scala> spark.udf.register("fncleanBirthplace", fncleanBirthplace(_:String))
res11: org.apache.spark.sql.expressions.UserDefinedFunction = UserDefinedFunction(<function1>,StringType,Some(List(StringType)))
Python:
>>> from pyspark.sql.types import StringType
>>> sqlContext.registerFunction("cleanDateUDF",fncleanDate, StringType())
>>> sqlContext.registerFunction( "cleanBirthplaceUDF",fncleanBirthplace, StringType())
使用 UDF 清理数据框。执行以下清理操作:
-
调用 UDF
fncleanDate
和fncleanBirthplace
来修正出生地和国家。 -
从
award_year
中减去出生年份以获取获奖时的age
。 -
保留
race
和award
原样。
Scala:
//Create cleaned data frame
scala> var cleaned_df = spark.sql (
"""SELECT fncleanDate (date_of_birth) dob,
fncleanBirthplace(birthplace) birthplace,
substring_index(fncleanBirthplace(birthplace),' ',-1)
country,
(award_year - substring_index(fncleanDate( date_of_birth),'-',-1)) age, race, award FROM awards""")
cleaned_df: org.apache.spark.sql.DataFrame = [dob: string, birthplace: string ... 4 more fields]
Python:
//Create cleaned data frame
>>> from pyspark.sql.functions import substring_index>>> cleaned_df = spark.sql ( """SELECT cleanDateUDF (date_of_birth) dob, cleanBirthplaceUDF(birthplace) birthplace, substring_index(cleanBirthplaceUDF(birthplace),' ',-1) country, (award_year - substring_index(cleanDateUDF( date_of_birth), '-',-1)) age, race, award FROM awards""")
最后一行需要一些解释。UDF 的使用类似于 SQL 函数,且表达式被别名为有意义的名称。我们添加了一个计算列 age
,因为我们希望验证年龄的影响。substring_index
函数搜索第一个参数中的第二个参数。-1
表示从右侧查找第一个出现的值。
制定假设
假设是你关于结果的最佳猜测。你根据问题、与利益相关者的对话以及查看数据来形成初步假设。你可能会为给定的问题形成一个或多个假设。这个初步假设作为一张路线图,引导你进行探索性分析。制定假设对于统计学上批准或不批准一个声明非常重要,而不仅仅是通过查看数据矩阵或视觉效果。这是因为仅凭数据可能会导致错误的认知,甚至可能具有误导性。
现在你知道,最终结果可能会证明假设是正确的,也可能证明是假设错误的。对于我们本课考虑的案例研究,我们得出了以下初步假设:
-
获奖者大多是白人
-
大多数获奖者来自美国
-
最佳演员和最佳女演员往往比最佳导演年轻
现在我们已经正式化了假设,准备好进行生命周期中的下一步。
数据探索
现在我们有了一个干净的数据框,其中包含相关数据和初步假设,是时候真正探索我们拥有的内容了。DataFrames 抽象提供了像group by
这样的函数,帮助你进行探索。你也可以将清理过的数据框注册为表格,并运行经过时间验证的 SQL 语句来完成相同的操作。
这也是绘制一些图表的时机。这一可视化阶段是数据可视化章节中提到的探索性分析。这个探索的目标在很大程度上受你从业务利益相关者那里获得的初步信息和假设的影响。换句话说,你与利益相关者的讨论帮助你了解要寻找什么。
有一些通用的指南适用于几乎所有的数据科学任务,但也会根据不同的使用场景而有所不同。我们来看一些通用的指南:
-
查找缺失数据并进行处理。我们在第五章,在 Spark 上进行数据分析中已经讨论过各种处理方法。
-
查找数据集中的离群值并进行处理。我们也讨论过这一方面。请注意,有些情况下,我们认为的离群值和正常数据点可能会根据使用场景而变化。
-
执行单变量分析,在这个过程中,你会分别探索数据集中的每一个变量。频率分布或百分位分布是非常常见的。也许你可以绘制一些图表,以获得更清晰的理解。这还将帮助你在进行数据建模之前准备数据。
-
验证你的初步假设。
-
检查数值数据的最小值和最大值。如果某一列的变化范围过大,可能需要进行数据标准化或缩放处理。
-
检查分类数据中的不同值(如城市名等字符串值)及其频率。如果某一列的不同值(也就是层级)过多,可能需要寻找减少层级数量的方法。如果某一层级几乎总是出现,那么该列对于模型区分可能的结果没有帮助,这样的列很可能是移除的候选。在探索阶段,你只需找出这些候选列,真正的操作可以留给数据准备阶段来处理。
在我们当前的数据集中,没有缺失数据,也没有可能带来挑战的数值数据。然而,在处理无效日期时,可能会有一些缺失值出现。因此,以下代码涵盖了剩余的操作项。假设cleaned_df
已经创建:
Scala/Python:
cleaned_df = cleaned_df.na.drop //Drop rows with missing values
cleaned_df.groupBy("award","country").count().sort("country","award","count").show(4,False)
+-----------------------+---------+-----+
|award |country |count|
+-----------------------+---------+-----+
|Best Actor |Australia|1 |
|Best Actress |Australia|1 |
|Best Supporting Actor |Australia|1 |
|Best Supporting Actress|Australia|1 |
+-----------------------+---------+-----+
//Re-register data as table
cleaned_df.createOrReplaceTempView("awards")
//Find out levels (distinct values) in each categorical variable
spark.sql("SELECT count(distinct country) country_count, count(distinct race) race_count, count(distinct award) award_count from awards").show()
+-------------+----------+-----------+
|country_count|race_count|award_count|
+-------------+----------+-----------+
| 34| 6| 5|
+-------------+----------+-----------+
以下可视化图表对应于最初的假设。请注意,我们的两个假设是正确的,但第三个假设是错误的。这些可视化图表是使用 zeppelins 创建的:
https://github.com/OpenDocCN/freelearn-ds-pt5-zh/raw/master/docs/spk-ds/img/image_10_002.jpg
需要注意的是,并非所有假设都可以仅通过可视化来验证,因为可视化有时可能会具有误导性。因此,需要进行适当的统计检验,例如 t 检验、方差分析(ANOVA)、卡方检验、相关性检验等,根据实际情况进行。我们将在本节中不详细讨论这些内容。请参阅第五章,Spark 上的数据分析,了解更多详情。
数据准备
数据探索阶段帮助我们识别了在进入建模阶段之前需要修复的所有问题。每个问题都需要仔细思考和讨论,以选择最佳的修复方法。以下是一些常见问题和可能的解决方法。最佳修复方法取决于当前的问题和/或业务背景。
类别变量中的层级过多
这是我们面临的最常见问题之一。解决这个问题的方法取决于多个因素:
-
如果某列几乎总是唯一的,例如,它是一个交易 ID 或时间戳,那么除非你从中衍生出新的特征,否则它不会参与建模。你可以安全地删除该列而不会丢失任何信息内容。通常,你会在数据清理阶段就删除它。
-
如果可以用较粗粒度的层级(例如,使用州或国家代替城市)来替代当前层级,并且在当前上下文中是合理的,那么通常这是解决此问题的最佳方法。
-
你可能需要为每个不同的层级添加一个虚拟列,值为 0 或 1。例如,如果你在单个列中有 100 个层级,你可以添加 100 列。每次观察(行)中最多只有一个列会为 1。这就是独热编码(one-hot encoding),Spark 通过
ml.features
包默认提供此功能。 -
另一种选择是保留最频繁的层级。你甚至可以将每个这些层级与一个“较近”的主层级进行关联。此外,你可以将其余的层级归为一个单独的桶,例如
Others
。 -
对于层级数量的绝对限制并没有硬性规定。这取决于每个特性所需的粒度以及性能限制。
当前数据集中,类别变量country
有太多的层级。我们选择保留最频繁的层级,并将其余的层级归为Others
:
Scala:
//Country has too many values. Retain top ones and bundle the rest
//Check out top 6 countries with most awards.
scala> val top_countries_df = spark.sql("SELECT country, count(*) freq FROM awards GROUP BY country ORDER BY freq DESC LIMIT 6")
top_countries_df: org.apache.spark.sql.DataFrame = [country: string, freq: bigint]
scala> top_countries_df.show()
+-------+----+
|country|freq|
+-------+----+
| USA| 289|
|England| 57|
| France| 9|
| Canada| 8|
| Italy| 7|
|Austria| 7|
+-------+----+
//Prepare top_countries list
scala> val top_countries = top_countries_df.select("country").collect().map(x => x(0).toString)
top_countries: Array[String] = Array(USA, England, New York City, France, Canada, Italy)
//UDF to fix country. Retain top 6 and bundle the rest into "Others"
scala> import org.apache.spark.sql.functions.udf
import org.apache.spark.sql.functions.udf
scala > val setCountry = udf ((s: String) =>
{ if (top_countries.contains(s)) {s} else {"Others"}})
setCountry: org.apache.spark.sql.expressions.UserDefinedFunction = UserDefinedFunction(<function1>,StringType,Some(List(StringType)))
//Apply udf to overwrite country
scala> cleaned_df = cleaned_df.withColumn("country", setCountry(cleaned_df("country")))
cleaned_df: org.apache.spark.sql.DataFrame = [dob: string, birthplace: string ... 4 more fields]
Python:
//Check out top 6 countries with most awards.
>>> top_countries_df = spark.sql("SELECT country, count(*) freq FROM awards GROUP BY country ORDER BY freq DESC LIMIT 6")
>>> top_countries_df.show()
+-------+----+
|country|freq|
+-------+----+
| USA| 289|
|England| 57|
| France| 9|
| Canada| 8|
| Italy| 7|
|Austria| 7|
+-------+----+
>>> top_countries = [x[0] for x in top_countries_df.select("country").collect()]
//UDF to fix country. Retain top 6 and bundle the rest into "Others"
>>> from pyspark.sql.functions import udf
>>> from pyspark.sql.types import StringType
>>> setCountry = udf(lambda s: s if s in top_countries else "Others", StringType())
//Apply UDF
>>> cleaned_df = cleaned_df.withColumn("country", setCountry(cleaned_df["country"]))
数值变量的变化过大
有时数值数据的变化可能跨越几个数量级。例如,如果你查看个人的年收入,它可能会有很大差异。Z-score 标准化(标准化处理)和最小最大值缩放是两种常用的数据处理方法。Spark 在ml.features
包中已经包含了这两种转换方法。
我们当前的数据集中没有这样的变量。我们唯一的数值型变量是年龄,其值始终为两位数。这样就少了一个需要解决的问题。
请注意,并非所有数据都需要进行标准化。如果你正在比较两个尺度不同的变量,或者使用聚类算法、SVM 分类器,或者其他真正需要标准化数据的场景时,你可以对数据进行标准化处理。
缺失数据
这是一个主要的关注点。任何目标值本身缺失的观测数据应该从训练数据中移除。其余的观测数据可以保留,并填补一些缺失值,或者根据需求移除。在填补缺失值时,你需要非常小心;否则可能导致误导性输出!直接在连续变量的空白单元格中填入平均值似乎很简单,但这可能不是正确的方法。
我们当前的案例研究没有缺失数据,因此没有需要处理的情况。然而,让我们看一个例子。
假设你正在处理一个学生数据集,其中包含了从 1 班到 5 班的数据。如果有一些缺失的年龄
值,而你仅仅通过求整列的平均值来填补,那么这就会成为一个离群点,并且可能导致模糊的结果。你可以选择仅计算学生所在班级的平均值,并用该值填补。这至少是一个更好的方法,但可能不是完美的。在大多数情况下,你还需要对其他变量给予一定的权重。如果这样做,你可能最终会构建一个预测模型来寻找缺失的值,这也是一个很好的方法!
连续数据
数值数据通常是连续的,必须进行离散化,因为这是某些算法的前提条件。它通常被拆分成不同的区间或值范围。然而,也可能存在一些情况,你不仅仅是根据数据的范围均匀分桶,可能还需要考虑方差、标准差或任何其他适用的原因来正确地分桶。现在,决定桶的数量也是数据科学家的自由裁量权,但这也需要仔细分析。桶太少会降低粒度,桶太多则和类别级别太多差不多。在我们的案例研究中,age
就是这种数据的一个例子,我们需要将其离散化。我们将其拆分成不同的区间。例如,看看这个管道阶段,它将age
转换为 10 个桶:
Scala:
scala> val splits = Array(Double.NegativeInfinity, 35.0, 45.0, 55.0,
Double.PositiveInfinity)
splits: Array[Double] = Array(-Infinity, 35.0, 45.0, 55.0, Infinity)
scala> val bucketizer = new Bucketizer().setSplits(splits).
setInputCol("age").setOutputCol("age_buckets")
bucketizer: org.apache.spark.ml.feature.Bucketizer = bucketizer_a25c5d90ac14
Python:
>>> splits = [-float("inf"), 35.0, 45.0, 55.0,
float("inf")]
>>> bucketizer = Bucketizer(splits = splits, inputCol = "age",
outputCol = "age_buckets")
类别数据
我们已经讨论了将连续数据离散化并转换为类别或区间的必要性。我们还讨论了引入虚拟变量,每个类别变量的不同值都有一个虚拟变量。还有一种常见的数据准备做法是将类别级别转换为数值(离散)数据。这是必要的,因为许多机器学习算法需要处理数值数据、整数或实数,或者某些其他情况可能要求这样做。因此,我们需要将类别数据转换为数值数据。
这种方法可能会有一些缺点。将固有的无序数据引入顺序有时可能不合逻辑。例如,将数字 0、1、2、3 分别赋给颜色“红色”、“绿色”、“蓝色”和“黑色”是没有意义的。因为我们不能说“红色”距离“绿色”一单位远,“绿色”距离“蓝色”也一样远!在许多此类情况下,若适用,引入虚拟变量更有意义。
准备数据
在讨论了常见问题和可能的解决方法之后,让我们看看如何准备我们当前的数据集。我们已经涵盖了与太多类别级别相关的代码修复。下面的示例展示了其余部分。它将所有特征转换为单个特征列。它还为测试模型预留了一些数据。这段代码重度依赖于ml.features
包,该包旨在支持数据准备阶段。请注意,这段代码只是定义了需要做的工作。转换尚未执行,这些将在后续定义的管道中成为阶段。执行被推迟到尽可能晚,直到实际模型构建时才会执行。Catalyst 优化器会找到实现管道的最佳路径:
Scala:
//Define pipeline to convert categorical labels to numerical labels
scala> import org.apache.spark.ml.feature.{StringIndexer, Bucketizer, VectorAssembler}
import org.apache.spark.ml.feature.{StringIndexer, Bucketizer, VectorAssembler}
scala> import org.apache.spark.ml.Pipeline
import org.apache.spark.ml.Pipeline
//Race
scala> val raceIdxer = new StringIndexer().
setInputCol("race").setOutputCol("raceIdx")
raceIdxer: org.apache.spark.ml.feature.StringIndexer = strIdx_80eddaa022e6
//Award (prediction target)
scala> val awardIdxer = new StringIndexer().
setInputCol("award").setOutputCol("awardIdx")
awardIdxer: org.apache.spark.ml.feature.StringIndexer = strIdx_256fe36d1436
//Country
scala> val countryIdxer = new StringIndexer().
setInputCol("country").setOutputCol("countryIdx")
countryIdxer: org.apache.spark.ml.feature.StringIndexer = strIdx_c73a073553a2
//Convert continuous variable age to buckets
scala> val splits = Array(Double.NegativeInfinity, 35.0, 45.0, 55.0,
Double.PositiveInfinity)
splits: Array[Double] = Array(-Infinity, 35.0, 45.0, 55.0, Infinity)
scala> val bucketizer = new Bucketizer().setSplits(splits).
setInputCol("age").setOutputCol("age_buckets")
bucketizer: org.apache.spark.ml.feature.Bucketizer = bucketizer_a25c5d90ac14
//Prepare numerical feature vector by clubbing all individual features
scala> val assembler = new VectorAssembler().setInputCols(Array("raceIdx",
"age_buckets","countryIdx")).setOutputCol("features")
assembler: org.apache.spark.ml.feature.VectorAssembler = vecAssembler_8cf17ee0cd60
//Define data preparation pipeline
scala> val dp_pipeline = new Pipeline().setStages(
Array(raceIdxer,awardIdxer, countryIdxer, bucketizer, assembler))
dp_pipeline: org.apache.spark.ml.Pipeline = pipeline_06717d17140b
//Transform dataset
scala> cleaned_df = dp_pipeline.fit(cleaned_df).transform(cleaned_df)
cleaned_df: org.apache.spark.sql.DataFrame = [dob: string, birthplace: string ... 9 more fields]
//Split data into train and test datasets
scala> val Array(trainData, testData) =
cleaned_df.randomSplit(Array(0.7, 0.3))
trainData: org.apache.spark.sql.Dataset[org.apache.spark.sql.Row] = [dob: string, birthplace: string ... 9 more fields]
testData: org.apache.spark.sql.Dataset[org.apache.spark.sql.Row] = [dob: string, birthplace: string ... 9 more fields]
Python:
//Define pipeline to convert categorical labels to numcerical labels
>>> from pyspark.ml.feature import StringIndexer, Bucketizer, VectorAssembler
>>> from pyspark.ml import Pipelin
//Race
>>> raceIdxer = StringIndexer(inputCol= "race", outputCol="raceIdx")
//Award (prediction target)
>>> awardIdxer = StringIndexer(inputCol = "award", outputCol="awardIdx")
//Country
>>> countryIdxer = StringIndexer(inputCol = "country", outputCol = "countryIdx")
//Convert continuous variable age to buckets
>>> splits = [-float("inf"), 35.0, 45.0, 55.0,
float("inf")]
>>> bucketizer = Bucketizer(splits = splits, inputCol = "age",
outputCol = "age_buckets")
>>>
//Prepare numerical feature vector by clubbing all individual features
>>> assembler = VectorAssembler(inputCols = ["raceIdx",
"age_buckets","countryIdx"], outputCol = "features")
//Define data preparation pipeline
>>> dp_pipeline = Pipeline(stages = [raceIdxer,
awardIdxer, countryIdxer, bucketizer, assembler])
//Transform dataset
>>> cleaned_df = dp_pipeline.fit(cleaned_df).transform(cleaned_df)
>>> cleaned_df.columns
['dob', 'birthplace', 'country', 'age', 'race', 'award', 'raceIdx', 'awardIdx', 'countryIdx', 'age_buckets', 'features']
//Split data into train and test datasets
>>> trainData, testData = cleaned_df.randomSplit([0.7, 0.3])
在完成所有数据准备工作后,你将得到一个完全由数字组成的、没有缺失值且每个属性的值都处于可管理水平的数据集。你可能已经删除了那些对当前分析贡献不大的属性。这就是我们所说的最终数据矩阵。现在,你已经准备好开始建模数据了。首先,你将源数据分成训练数据和测试数据。模型使用训练数据“训练”,然后使用测试数据“测试”。请注意,数据的划分是随机的,如果你重新划分,可能会得到不同的训练集和测试集。
模型构建
模型是事物的表现形式,是对现实的渲染或描述。就像一座物理建筑的模型一样,数据科学模型试图理解现实;在这种情况下,现实是特征与预测变量之间的潜在关系。它们可能不是 100%准确,但仍然非常有用,能够基于数据为我们的业务领域提供深刻的见解。
有多种机器学习算法帮助我们进行数据建模,Spark 提供了其中的许多算法。然而,选择构建哪个模型依然是一个价值百万美元的问题。这取决于多个因素,比如可解释性与准确度之间的权衡、手头的数据量、分类或数值型变量、时间和内存的限制等等。在下面的代码示例中,我们随机训练了几个模型,向你展示如何进行。
我们将根据种族、年龄和国家预测奖项类型。我们将使用 DecisionTreeClassifier、RandomForestClassifier 和 OneVsRest 算法。这三种算法是随意选择的,它们都能处理多类别标签,并且容易理解。我们使用了ml
包提供的以下评估指标:
-
准确率:正确预测的观测值所占比例。
-
加权精度:精度是正确正类观测值与所有正类观测值的比率。加权精度考虑了各类的频率。
-
加权召回率:召回率是正类与实际正类的比率。实际正类是指真实正类和假阴性的总和。加权召回率考虑了各类的频率。
-
F1:默认的评估度量。它是精度和召回率的加权平均值。
Scala:
scala> import org.apache.spark.ml.Pipeline
import org.apache.spark.ml.Pipeline
scala> import org.apache.spark.ml.classification.DecisionTreeClassifier
import org.apache.spark.ml.classification.DecisionTreeClassifier
//Use Decision tree classifier
scala> val dtreeModel = new DecisionTreeClassifier().
setLabelCol("awardIdx").setFeaturesCol("features").
fit(trainData)
dtreeModel: org.apache.spark.ml.classification.DecisionTreeClassificationModel = DecisionTreeClassificationModel (uid=dtc_76c9e80680a7) of depth 5 with 39 nodes
//Run predictions using testData
scala> val dtree_predictions = dtreeModel.transform(testData)
dtree_predictions: org.apache.spark.sql.DataFrame = [dob: string, birthplace: string ... 12 more fields]
//Examine results. Your results may vary due to randomSplit
scala> dtree_predictions.select("award","awardIdx","prediction").show(4)
+--------------------+--------+----------+
| award|awardIdx|prediction|
+--------------------+--------+----------+
| Best Director| 1.0| 1.0|
| Best Actress| 0.0| 0.0|
| Best Actress| 0.0| 0.0|
|Best Supporting A...| 4.0| 3.0|
+--------------------+--------+----------+
//Compute prediction mismatch count
scala> dtree_predictions.filter(dtree_predictions("awardIdx") =!= dtree_predictions("prediction")).count()
res10: Long = 88
scala> testData.count
res11: Long = 126
//Predictions match with DecisionTreeClassifier model is about 30% ((126-88)*100/126)
//Train Random forest
scala> import org.apache.spark.ml.classification.RandomForestClassifier
import org.apache.spark.ml.classification.RandomForestClassifier
scala> import org.apache.spark.ml.classification.RandomForestClassificationModel
import org.apache.spark.ml.classification.RandomForestClassificationModel
scala> import org.apache.spark.ml.feature.{StringIndexer, IndexToString, VectorIndexer}
import org.apache.spark.ml.feature.{StringIndexer, IndexToString, VectorIndexer}
//Build model
scala> val RFmodel = new RandomForestClassifier().
setLabelCol("awardIdx").
setFeaturesCol("features").
setNumTrees(6).fit(trainData)
RFmodel: org.apache.spark.ml.classification.RandomForestClassificationModel = RandomForestClassificationModel (uid=rfc_c6fb8d764ade) with 6 trees
//Run predictions on the same test data using Random Forest model
scala> val RF_predictions = RFmodel.transform(testData)
RF_predictions: org.apache.spark.sql.DataFrame = [dob: string, birthplace: string ... 12 more fields]
//Check results
scala> RF_predictions.filter(RF_predictions("awardIdx") =!= RF_predictions("prediction")).count()
res29: Long = 87 //Roughly the same as DecisionTreeClassifier
//Try OneVsRest Logistic regression technique
scala> import org.apache.spark.ml.classification.{LogisticRegression, OneVsRest}
import org.apache.spark.ml.classification.{LogisticRegression, OneVsRest}
//This model requires a base classifier
scala> val classifier = new LogisticRegression().
setLabelCol("awardIdx").
setFeaturesCol("features").
setMaxIter(30).
setTol(1E-6).
setFitIntercept(true)
classifier: org.apache.spark.ml.classification.LogisticRegression = logreg_82cd24368c87
//Fit OneVsRest model
scala> val ovrModel = new OneVsRest().
setClassifier(classifier).
setLabelCol("awardIdx").
setFeaturesCol("features").
fit(trainData)
ovrModel: org.apache.spark.ml.classification.OneVsRestModel = oneVsRest_e696c41c0bcf
//Run predictions
scala> val OVR_predictions = ovrModel.transform(testData)
predictions: org.apache.spark.sql.DataFrame = [dob: string, birthplace: string ... 10 more fields]
//Check results
scala> OVR_predictions.filter(OVR_predictions("awardIdx") =!= OVR_predictions("prediction")).count()
res32: Long = 86 //Roughly the same as other models
Python:
>>> from pyspark.ml import Pipeline
>>> from pyspark.ml.classification import DecisionTreeClassifier
//Use Decision tree classifier
>>> dtreeModel = DecisionTreeClassifier(labelCol = "awardIdx", featuresCol="features").fit(trainData)
//Run predictions using testData
>>> dtree_predictions = dtreeModel.transform(testData)
//Examine results. Your results may vary due to randomSplit
>>> dtree_predictions.select("award","awardIdx","prediction").show(4)
+--------------------+--------+----------+
| award|awardIdx|prediction|
+--------------------+--------+----------+
| Best Director| 1.0| 4.0|
| Best Director| 1.0| 1.0|
| Best Director| 1.0| 1.0|
|Best Supporting A...| 4.0| 3.0|
+--------------------+--------+----------+
>>> dtree_predictions.filter(dtree_predictions["awardIdx"] != dtree_predictions["prediction"]).count()
92
>>> testData.count()
137
>>>
//Predictions match with DecisionTreeClassifier model is about 31% ((133-92)*100/133)
//Train Random forest
>>> from pyspark.ml.classification import RandomForestClassifier, RandomForestClassificationModel
>>> from pyspark.ml.feature import StringIndexer, IndexToString, VectorIndexer
>>> from pyspark.ml.evaluation import MulticlassClassificationEvaluator
//Build model
>>> RFmodel = RandomForestClassifier(labelCol = "awardIdx", featuresCol = "features", numTrees=6).fit(trainData)
//Run predictions on the same test data using Random Forest model
>>> RF_predictions = RFmodel.transform(testData)
//Check results
>>> RF_predictions.filter(RF_predictions["awardIdx"] != RF_predictions["prediction"]).count()
94 //Roughly the same as DecisionTreeClassifier
//Try OneVsRest Logistic regression technique
>>> from pyspark.ml.classification import LogisticRegression, OneVsRest
//This model requires a base classifier
>>> classifier = LogisticRegression(labelCol = "awardIdx", featuresCol="features",
maxIter = 30, tol=1E-6, fitIntercept = True)
//Fit OneVsRest model
>>> ovrModel = OneVsRest(classifier = classifier, labelCol = "awardIdx",
featuresCol = "features").fit(trainData)
//Run predictions
>>> OVR_predictions = ovrModel.transform(testData)
//Check results
>>> OVR_predictions.filter(OVR_predictions["awardIdx"] != OVR_predictions["prediction"]).count()
90 //Roughly the same as other models
到目前为止,我们已经尝试了几种模型,发现它们的表现大致相同。验证模型性能有许多其他方法,这取决于你使用的算法、业务背景以及所产生的结果。我们来看一下 spark.ml.evaluation
包中提供的一些评估指标:
Scala:
scala> import org.apache.spark.ml.evaluation.MulticlassClassificationEvaluator
import org.apache.spark.ml.evaluation.MulticlassClassificationEvaluator
//F1
scala> val f1_eval = new MulticlassClassificationEvaluator().
setLabelCol("awardIdx") //Default metric is F1
f1_eval: org.apache.spark.ml.evaluation.MulticlassClassificationEvaluator = mcEval_e855a949bb0e
//WeightedPrecision
scala> val wp_eval = new MulticlassClassificationEvaluator().
setMetricName("weightedPrecision").setLabelCol("awardIdx")
wp_eval: org.apache.spark.ml.evaluation.MulticlassClassificationEvaluator = mcEval_44fd64e29d0a
//WeightedRecall
scala> val wr_eval = new MulticlassClassificationEvaluator().
setMetricName("weightedRecall").setLabelCol("awardIdx")
wr_eval: org.apache.spark.ml.evaluation.MulticlassClassificationEvaluator = mcEval_aa341966305a
//Compute measures for all models
scala> val f1_eval_list = List (dtree_predictions, RF_predictions, OVR_predictions) map (
x => f1_eval.evaluate(x))
f1_eval_list: List[Double] = List(0.2330854098674473, 0.2330854098674473, 0.2330854098674473)
scala> val wp_eval_list = List (dtree_predictions, RF_predictions, OVR_predictions) map (
x => wp_eval.evaluate(x))
wp_eval_list: List[Double] = List(0.2661599224979506, 0.2661599224979506, 0.2661599224979506)
scala> val wr_eval_list = List (dtree_predictions, RF_predictions, OVR_predictions) map (
x => wr_eval.evaluate(x))
wr_eval_list: List[Double] = List(0.31746031746031744, 0.31746031746031744, 0.31746031746031744)
Python:
>>> from pyspark.ml.evaluation import MulticlassClassificationEvaluator
//F1
>>> f1_eval = MulticlassClassificationEvaluator(labelCol="awardIdx") //Default metric is F1
//WeightedPrecision
>>> wp_eval = MulticlassClassificationEvaluator(labelCol="awardIdx", metricName="weightedPrecision")
//WeightedRecall
>>> wr_eval = MulticlassClassificationEvaluator(labelCol="awardIdx", metricName="weightedRecall")
//Accuracy
>>> acc_eval = MulticlassClassificationEvaluator(labelCol="awardIdx", metricName="Accuracy")
//Compute measures for all models
>>> f1_eval_list = [ f1_eval.evaluate(x) for x in [dtree_predictions, RF_predictions, OVR_predictions]]
>>> wp_eval_list = [ wp_eval.evaluate(x) for x in [dtree_predictions, RF_predictions, OVR_predictions]]
>>> wr_eval_list = [ wr_eval.evaluate(x) for x in [dtree_predictions, RF_predictions, OVR_predictions]]
//Print results for DecisionTree, Random Forest and OneVsRest
>>> f1_eval_list
[0.2957949866055487, 0.2645186821042419, 0.2564967990214734]
>>> wp_eval_list
[0.3265407181548341, 0.31914852065228005, 0.25295826631254753]
>>> wr_eval_list
[0.3082706766917293, 0.2932330827067669, 0.3233082706766917]
输出:
决策树 | 随机森林 | OneVsRest | |
---|---|---|---|
F1 | 0.29579 | 0.26451 | 0.25649 |
加权精度 | 0.32654 | 0.26451 | 0.25295 |
加权召回率 | 0.30827 | 0.29323 | 0.32330 |
在验证模型性能后,你将需要尽可能地调整模型。调整可以在数据层面和算法层面进行。提供算法所期望的正确数据非常重要。问题在于,无论你输入什么数据,算法可能仍然会给出某些输出——它从不抱怨!因此,除了通过处理缺失值、处理单变量和多变量异常值等方式对数据进行清理外,你还可以创建更多相关的特征。这种特征工程通常被视为数据科学中最重要的部分。拥有一定的领域专业知识有助于更好地进行特征工程。至于算法层面的调整,总是有机会优化我们传递给算法的参数。你可以选择使用网格搜索来寻找最佳参数。此外,数据科学家应当自问,应该使用哪个损失函数及其原因,以及在 GD、SGD、L-BFGS 等算法中,应该选择哪种算法来优化损失函数及其原因。
请注意,前述方法仅用于演示如何在 Spark 上执行步骤。仅通过查看准确率选择某个算法可能不是最佳方法。选择算法取决于你所处理的数据类型、结果变量、业务问题/需求、计算挑战、可解释性等多种因素。
数据可视化
数据可视化 是从开始处理数据科学任务时就需要时常使用的工具。在构建任何模型之前,最好先对每个变量进行可视化,以查看它们的分布,了解它们的特征,并找出异常值以便处理。诸如散点图、箱形图、条形图等简单工具是此类目的的多功能且便捷的工具。此外,在大多数步骤中你还需要使用可视化工具,以确保你正在朝正确的方向前进。
每次你与业务用户或利益相关者合作时,通过可视化来传达分析结果总是一个好习惯。可视化可以以更有意义的方式容纳更多的数据,并且本质上具有直观性。
请注意,大多数数据科学任务的结果通常通过可视化和仪表板呈现给业务用户。我们已经有专门的章节讲解这一主题,因此这里就不再深入探讨。
向业务用户传达结果
在现实生活中,通常需要你间歇性地与业务方进行沟通。在得出最终的可投入生产的模型之前,你可能需要建立多个模型,并将结果传达给业务方。
一个可实施的模型并不总是依赖于准确性;你可能需要引入其他指标,如灵敏度、特异性,或者 ROC 曲线,还可以通过可视化图表(如增益/提升图表)或具有统计显著性的 K-S 检验输出展示结果。需要注意的是,这些技术需要业务用户的输入。这些输入通常会指导你如何构建模型或设置阈值。让我们通过几个例子更好地理解它是如何工作的:
-
如果一个回归模型预测某个事件发生的概率,那么盲目将阈值设置为 0.5,并假设大于 0.5 的是 1,小于 0.5 的是 0,可能并不是最好的方法!你可以使用 ROC 曲线并做出更科学或更有逻辑性的决策。
-
对癌症检测的假阴性预测可能是完全不可取的!这是一个极端的生命风险案例。
-
相比于发送纸质副本,电子邮件营销更便宜。因此,业务方可能决定向那些预测概率低于 0.5(例如 0.35)的收件人发送电子邮件。
注意,前述决策受到业务用户或问题所有者的强烈影响,数据科学家与他们密切合作,以决定此类案例。
再次强调,如前所述,正确的可视化是与业务沟通结果的最优方式。
总结
在本章中,我们进行了一个案例研究,并完成了数据分析生命周期的整个过程。在构建数据产品的过程中,我们应用了前几章中获得的知识。我们提出了一个业务问题,形成了初步假设,获取了数据,并为建模做了准备。我们尝试了多种模型,最终找到了合适的模型。
在下一章,也是最后一章,我们将讨论如何使用 Spark 构建实际应用。
参考文献
www2.sas.com/proceedings/forum2007/073-2007.pdf
。
azure.microsoft.com/en-in/documentation/articles/machine-learning-algorithm-choice/
。
www.cs.cornell.edu/courses/cs578/2003fa/performance_measures.pdf
。
第十一章:构建数据科学应用
数据科学应用正在引起广泛关注,主要因为它们在利用数据并提取可消费的结果方面的巨大潜力。已经有几个成功的数据产品对我们的日常生活产生了深远影响。无处不在的推荐系统、电子邮件垃圾邮件过滤器、定向广告和新闻内容已经成为生活的一部分。音乐和电影也已成为数据产品,从 iTunes 和 Netflix 等平台流媒体播放。企业,尤其是在零售等领域,正在积极寻求通过数据驱动的方法研究市场和客户行为,从而获得竞争优势。
在前面的章节中,我们已经讨论了数据分析工作流的模型构建阶段。但模型的真正价值体现在它实际部署到生产系统中的时候。最终产品,即数据科学工作流的成果,是一个已操作化的数据产品。在本章中,我们将讨论数据分析工作流的这一关键阶段。我们不会涉及具体的代码片段,而是退一步,全面了解整个过程,包括非技术性方面。
完整的图景不仅限于开发过程。它还包括用户应用程序、Spark 本身的开发,以及大数据领域中快速变化的情况。我们将首先从用户应用程序的开发过程开始,并讨论每个阶段的各种选项。接着,我们将深入了解最新 Spark 2.0 版本中的特性和改进以及未来计划。最后,我们将尝试全面概述大数据趋势,特别是 Hadoop 生态系统。此外,每个部分的末尾会提供相关参考资料和有用链接,供读者进一步了解特定的背景信息。
开发范围
数据分析工作流大致可分为两个阶段:构建阶段和操作化阶段。第一阶段通常是一次性的工作,且需要大量人工干预。一旦我们获得了合理的最终结果,就可以准备将产品操作化。第二阶段从第一阶段生成的模型开始,并将其作为生产工作流的一部分进行部署。在本节中,我们将讨论以下内容:
-
期望
-
演示选项
-
开发与测试
-
数据质量管理
期望
数据科学应用的主要目标是构建“可操作”的洞察,"可操作"是关键字。许多使用案例,如欺诈检测,要求洞察必须生成并以可消费的方式接近实时地提供,才能期待有任何行动的可能。数据产品的最终用户根据使用案例而不同。它们可能是电子商务网站的客户,或者是某大型企业的决策者。最终用户不一定总是人类,可能是金融机构中的风险评估软件工具。单一的通用方法并不适用于许多软件产品,数据产品也不例外。然而,数据产品有一些共同的期望,如下所列:
-
首要的期望是,基于真实世界数据的洞察生成时间框架应处于“可操作”时间范围内。实际的时间框架会根据使用案例而有所不同。
-
数据产品应能够融入某些(通常是已经存在的)生产工作流程中。
-
洞察结果应被转化为人们可以使用的东西,而不是晦涩难懂的数字或难以解释的图表。展示方式应该是简洁的。
-
数据产品应该具备根据输入的数据自我调整(自适应)的能力。
-
理想情况下,必须有某种方式接收人工反馈,并将其用作自我调节的来源。
-
应该有一个机制,定期且自动地定量评估其有效性。
演示选项
数据产品的多样性要求不同的展示方式。有时候,数据分析的最终结果是发布研究论文。有时候,它可能是仪表板的一部分,成为多个来源在同一网页上发布结果的其中之一。它们可能是显式的,目标是供人类使用,或者是隐式的,供其他软件应用使用。你可能会使用像 Spark 这样的通用引擎来构建你的解决方案,但展示方式必须高度对准目标用户群体。
有时候,你所需要做的只是写一封电子邮件,分享你的发现,或者仅仅导出一个 CSV 文件的洞察结果。或者,你可能需要围绕数据产品开发一个专门的 Web 应用程序。这里讨论了一些常见的选项,你必须选择适合当前问题的那一个。
互动笔记本
互动笔记本是网络应用程序,允许你创建和分享包含代码块、结果、方程式、图像、视频和解释文本的文档。它们可以作为可执行文档或具有可视化和方程式支持的 REPL Shell 进行查看。这些文档可以导出为 PDF、Markdown 或 HTML 格式。笔记本包含多个“内核”或“计算引擎”,用于执行代码块。
互动式笔记本是如果你的数据分析工作流的最终目标是生成书面报告时最合适的选择。市面上有几种笔记本,并且其中很多都支持 Spark。这些笔记本在探索阶段也非常有用。我们在前几章已经介绍过 IPython 和 Zeppelin 笔记本。
参考文献
-
IPython Notebook:数据科学的综合工具:
conferences.oreilly.com/strata/strata2013/public/schedule/detail/27233
-
Sparkly Notebook:与 Spark 进行交互式分析与可视化:
www.slideshare.net/felixcss/sparkly-notebook-interactive-analysis-and-visualization-with-spark
Web API
应用程序编程接口(API)是软件与软件之间的接口;它是一个描述可用功能、如何使用这些功能以及输入输出是什么的规范。软件(服务)提供方将其某些功能暴露为 API。开发者可以开发一个软件组件来消费这个 API。例如,Twitter 提供 API 来获取或发布数据到 Twitter,或者通过编程方式查询数据。一位 Spark 爱好者可以编写一个软件组件,自动收集所有关于 #Spark 的推文,按需求进行分类,并将这些数据发布到他们的个人网站。Web API 是一种接口,其中接口被定义为一组超文本传输协议(HTTP)请求消息,并附带响应消息结构的定义。如今,RESTful(表现层状态转移)已成为事实上的标准。
你可以将你的数据产品实现为一个 API,也许这是最强大的选择。它可以插入到一个或多个应用中,比如管理仪表板以及市场营销分析工作流。你可能会开发一个特定领域的“洞察即服务”,作为一个带订阅模式的公共 Web API。Web API 的简洁性和普及性使其成为构建数据产品时最具吸引力的选择。
参考文献
-
应用程序编程接口:
en.wikipedia.org/wiki/Application_programming_interface
-
准备好使用 API 了吗?三步解锁数据经济最有前景的渠道:
www.forbes.com/sites/mckinsey/2014/01/07/ready-for-apis-three-steps-to-unlock-the-data-economys-most-promising-channel/#61e7103b89e5
-
如何基于大数据发展洞察即服务:
www.kdnuggets.com/2015/12/insights-as-a-service-big-data.html
PMML 和 PFA
有时你可能需要以其他数据挖掘工具能理解的方式暴露你的模型。模型以及所有的预处理和后处理步骤应该转换为标准格式。PMML 和 PFA 就是数据挖掘领域的两种标准格式。
预测模型标记语言(PMML)是一种基于 XML 的预测模型交换格式,Apache Spark API 可以直接将模型转换为 PMML。一个 PMML 消息可以包含大量的数据转换,以及一个或多个预测模型。不同的数据挖掘工具可以在无需定制代码的情况下导入或导出 PMML 消息。
分析的可移植格式(PFA)是下一代预测模型交换格式。它交换 JSON 文档,并直接继承了 JSON 文档相比 XML 文档的所有优点。此外,PFA 比 PMML 更具灵活性。
参考资料
-
PMML 常见问题解答:预测模型标记语言:
www.kdnuggets.com/2013/01/pmml-faq-predictive-model-markup-language.html
-
分析的可移植格式:将模型移至生产环境:
www.kdnuggets.com/2016/01/portable-format-analytics-models-production.html
-
PFA 是做什么的?:
dmg.org/pfa/docs/motivation/
开发与测试
Apache Spark 是一个通用的集群计算系统,可以独立运行,也可以在多个现有集群管理器上运行,如 Apache Mesos、Hadoop、Yarn 和 Amazon EC2。此外,许多大数据和企业软件公司已经将 Spark 集成到他们的产品中:Microsoft Azure HDInsight、Cloudera、IBM Analytics for Apache Spark、SAP HANA,等等。Databricks 是由 Apache Spark 创始人创办的公司,提供自己的数据科学工作流产品,涵盖从数据获取到生产的全过程。你的责任是了解组织的需求和现有的人才储备,并决定哪个选项最适合你。
无论选择哪种选项,都应遵循软件开发生命周期中的常规最佳实践,如版本控制和同行评审。在适用的情况下尽量使用高级 API。生产环境中使用的数据转换管道应该与构建模型时使用的相同。记录在数据分析工作流中出现的任何问题,这些问题往往可以促使业务流程的改进。
一如既往,测试对产品的成功至关重要。你必须维护一套自动化脚本,提供易于理解的测试结果。最少的测试用例应该覆盖以下内容:
-
遵守时间框架和资源消耗要求
-
对不良数据(例如数据类型违规)的弹性
-
New value in a categorical feature that was not encountered during the model building phase
-
Very little data or too heavy data that is expected in the target production system
Monitor logs, resource utilization, and so on to uncover any performance bottlenecks. The Spark UI provides a wealth of information to monitor Spark applications. The following are some common tips that will help you improve performance:
-
Cache any input or intermediate data that might be used multiple times.
-
Look at the Spark UI and identify jobs that are causing a lot of shuffle. Check the code and see whether you can reduce the shuffles.
-
Actions may transfer the data from workers to the driver. See that you are not transferring any data that is not absolutely necessary.
-
Stragglers; that run slower than others; ”may increase the overall job completion time. There may be several reasons for a straggler. If a job is running slow due to a slow node, you may set
spark.speculation
totrue
. Then Spark automatically relaunches such a task on a different node. Otherwise, you may have to revisit the logic and see whether it can be improved.
References
-
Investigating Spark’s performance:
radar.oreilly.com/2015/04/investigating-sparks-performance.html
-
Tuning and Debugging in Apache Spark by Patrick Wendell:
sparkhub.databricks.com/video/tuning-and-debugging-apache-spark/
-
How to tune your Apache Spark jobs:
blog.cloudera.com/blog/2015/03/how-to-tune-your-apache-spark-jobs-part-1/
和 part 2
Data quality management
At the outset, let’s not forget that we are trying to build fault-tolerant software data products from unreliable, often unstructured, and uncontrolled data sources. So data quality management gains even more importance in a data science workflow. Sometimes the data may solely come from controlled data sources, such as automated internal process workflows in an organization. But in all other cases, you need to carefully craft your data cleansing processes to protect the subsequent processing.
Metadata consists of the structure and meaning of data, and obviously the most critical repository to work with. It is the information about the structure of individual data sources and what each component in that structure means. You may not always be able to write some script and extract this data. A single data source may contain data with different structures or an individual component (column) may mean different things during different times. A label such as owner or high may mean different things in different data sources. Collecting and understanding all such nuances and documenting is a tedious, iterative task. Standardization of metadata is a prerequisite to data transformation development.
Some broad guidelines that are applicable to most use cases are listed here:
-
所有数据源必须进行版本控制并加上时间戳
-
数据质量管理过程通常需要最高层次的主管部门参与
-
屏蔽或匿名化敏感数据
-
一个常常被忽视的重要步骤是保持可追溯性;即每个数据元素(比如一行)与其原始来源之间的链接
Scala 的优势
Apache Spark 允许你用 Python、R、Java 或 Scala 编写应用程序。随着这种灵活性的出现,你也需要承担选择适合自己需求的编程语言的责任。不过,无论你通常选择哪种语言,你可能都希望考虑在 Spark 驱动的应用程序中使用 Scala。在本节中,我们将解释为什么这么做。
让我们稍微跑题,首先高层次地了解一下命令式和函数式编程范式。像 C、Python 和 Java 这样的语言属于命令式编程范式。在命令式编程范式中,程序是一系列指令,并且它有一个程序状态。程序状态通常表现为在任何给定时刻变量及其值的集合。赋值和重新赋值是比较常见的。变量值在执行过程中会随着一个或多个函数的执行而变化。函数中的变量值修改不仅限于局部变量。全局变量和公共类变量就是此类变量的例子。
相比之下,用函数式编程语言如 Erlang 编写的程序可以看作是无状态的表达式求值器。数据是不可变的。如果函数以相同的输入参数被调用,那么它应该产生相同的结果(即参照透明性)。这是由于没有受到全局变量等变量上下文的干扰。这意味着函数评估的顺序不重要。函数可以作为参数传递给其他函数。递归调用取代了循环。无状态性使得并行编程变得更加容易,因为它消除了锁和潜在死锁的需求。当执行顺序不重要时,协调变得更为简化。这些因素使得函数式编程范式与并行编程非常契合。
纯函数式编程语言难以使用,因为大多数程序都需要状态的改变。包括老牌 Lisp 在内的大多数函数式编程语言都允许将数据存储在变量中(副作用)。一些语言,比如 Scala,融合了多种编程范式。
回到 Scala,它是一种基于 JVM 的静态类型多范式编程语言。其内建的类型推断机制允许程序员省略一些冗余的类型信息。这使得 Scala 在保持良好编译时检查和快速运行时的同时,具备了动态语言的灵活性。Scala 是面向对象的语言,意味着每个值都是一个对象,包括数值。函数是第一类对象,可以作为任何数据类型使用,并且可以作为参数传递给其他函数。由于 Scala 运行在 JVM 上,它与 Java 及其工具有良好的互操作性,Java 和 Scala 类可以自由混合使用。这意味着 Scala 可以轻松地与 Hadoop 生态系统进行交互。
在选择适合您应用的编程语言时,应该考虑所有这些因素。
Spark 开发状态
到 2015 年底,Apache Spark 已成为 Hadoop 生态系统中最活跃的项目之一,按贡献者数量来看。Spark 最初是 2009 年在 UC Berkeley AMPLAB 作为研究项目启动的,与 Apache Hadoop 等项目相比仍然相对年轻,且仍在积极开发中。2015 年有三次发布,从 1.3 到 1.5,包含了如 DataFrames API、SparkR 和 Project Tungsten 等特性。1.6 版本于 2016 年初发布,包含了新的数据集 API 和数据科学功能的扩展。Spark 2.0 于 2016 年 7 月发布,作为一个重要版本,包含了许多新特性和增强功能,值得单独拿出一节来介绍。
Spark 2.0 的特性和增强功能
Apache Spark 2.0 包含了三个主要的新特性以及其他一些性能改进和内部更改。本节尝试提供一个高层次的概述,并在需要时深入细节,帮助理解其概念。
统一数据集和数据框架
数据框架(DataFrames)是支持数据抽象的高级 API,其概念上等同于关系型数据库中的表格或 R 和 Python 中的 DataFrame(如 pandas 库)。数据集(Datasets)是数据框架 API 的扩展,提供类型安全的面向对象编程接口。数据集为数据框架增加了静态类型。在数据框架上定义结构为核心提供了优化信息,也有助于在分布式作业开始之前就能提前发现分析错误。
RDD、数据集(Datasets)和数据框(DataFrames)是可以互换的。RDD 仍然是低级 API。数据框、数据集和 SQL 共享相同的优化和执行管道。机器学习库使用的是数据框或数据集。数据框和数据集都在 Tungsten 上运行,Tungsten 是一个旨在提升运行时性能的计划。它们利用了 Tungsten 的快速内存编码技术,负责在 JVM 对象和 Spark 内部表示之间进行转换。相同的 API 也适用于流数据,引入了连续数据框的概念。
结构化流式计算
结构化流式 API 是基于 Spark SQL 引擎构建的高级 API,扩展了数据框和数据集。结构化流式计算统一了流处理、交互式查询和批处理查询。在大多数使用场景中,流数据需要与批处理和交互式查询结合,形成持续的应用程序。这些 API 旨在满足这一需求。Spark 负责增量和持续地执行流数据上的查询。
结构化流式计算的首次发布将专注于 ETL 工作负载。用户将能够指定输入、查询、触发器和输出类型。输入流在逻辑上等同于一个仅追加的表。用户可以像在传统 SQL 表上那样定义查询。触发器是一个时间框架,例如一秒。提供的输出模式包括完整输出、增量输出或就地更新(例如,数据库表)。
以这个例子为例:你可以对流数据进行聚合,通过 Spark SQL JDBC 服务器提供服务,并将其传递给数据库(例如 MySQL)用于下游应用。或者,你可以运行临时 SQL 查询,操作最新的数据。你还可以构建并应用机器学习模型。
项目 Tungsten 第二阶段
项目 Tungsten 的核心思想是通过本地内存管理和运行时代码生成,将 Spark 的性能推向接近硬件的极限。它首次包含在 Spark 1.4 中,并在 1.5 和 1.6 中进行了增强。其重点是通过以下几种方式显著提升 Spark 应用程序的内存和 CPU 效率:
-
明确管理内存并消除 JVM 对象模型和垃圾回收的开销。例如,一个四字节的字符串在 JVM 对象模型中大约占用 48 字节。由于 Spark 不是一个通用应用程序,并且比垃圾回收器更了解内存块的生命周期,它能够比 JVM 更高效地管理内存。
-
设计适合缓存的数据结构和算法。
-
Spark 执行代码生成,将查询的部分编译为 Java 字节码。这一过程已扩展到覆盖大多数内置表达式。
Spark 2.0 推出了第二阶段,它的速度提升了一个数量级,并且包括:
-
通过消除高开销的迭代器调用和跨多个操作符的融合,实现了整体阶段的代码生成,使生成的代码看起来像手工优化的代码
-
优化的输入和输出
接下来有什么?
预计 Apache Spark 2.1 将具备以下特性:
-
持续 SQL (CSQL)
-
BI 应用程序集成
-
支持更多的流式数据源和汇聚点
-
包括用于结构化流式处理的额外运算符和库
-
机器学习包的增强
-
Tungsten 中的列存储内存支持
大数据趋势
大数据处理在过去的十年中成为 IT 行业的一个重要组成部分。Apache Hadoop 和其他类似的努力致力于构建存储和处理海量数据的基础设施。Hadoop 平台已经运行超过 10 年,被认为成熟,几乎可以与大数据处理划上等号。Apache Spark 是一个通用的计算引擎,与 Hadoop 生态系统兼容,并且在 2015 年非常成功。
构建数据科学应用程序需要了解大数据领域和可用软件产品。我们需要仔细地映射适合我们需求的正确组件。有几个功能重叠的选择,挑选合适的工具比说起来容易得多。应用程序的成功在很大程度上取决于组合适当的技术和流程。好消息是,有几个开源选项可以降低大数据分析的成本;与此同时,你还可以通过像 Databricks 这样的公司支持的企业级端到端平台。除了手头的用例外,追踪行业趋势也同样重要。
最近 NOSQL 数据存储的激增,带来了它们自己的接口,即使它们不是关系型数据存储,也可能不遵循 ACID 属性。这是一个受欢迎的趋势,因为在关系型和非关系型数据存储之间收敛到一个单一的古老接口,提高了程序员的生产力。
在过去几十年里,运营(OLTP)和分析(OLAP)系统一直被维护为独立的系统,但这正是收敛正在发生的地方之一。这种收敛将我们带到几乎实时用例,如欺诈预防。Apache Kylin 是 Hadoop 生态系统中的一个开源分布式分析引擎,提供了一个极其快速的 OLAP 引擎。
物联网的出现加速了实时和流式分析,引入了大量新的用例。云计算解放了组织的运营和 IT 管理开销,使它们可以集中精力于其核心竞争力,特别是在大数据处理方面。基于云的分析引擎,自助数据准备工具,自助 BI,及时数据仓库,高级分析,丰富媒体分析和敏捷分析是一些常用的流行词。大数据这个术语本身正在慢慢消失或变得隐含。
在大数据领域,有大量功能重叠的软件产品和库,如下图所示(http://mattturck.com/wp-content/uploads/2016/02/matt_turck_big_data_landscape_v11.png)。为你的应用选择合适的模块是一个艰巨但非常重要的任务。以下是一个简短的项目列表,帮助你入门。该列表排除了像 Cassandra 这样的流行名字,尽量包含具有互补功能的模块,并且大多数来自 Apache 软件基金会:
-
Apache Arrow (
arrow.apache.org/
) 是一个内存中的列式存储层,用于加速分析处理和数据交换。它是一个高性能、跨系统的内存数据表示,预计能带来 100 倍的性能提升。 -
Apache Parquet (
parquet.apache.org/
) 是一种列式存储格式。Spark SQL 提供对读取和写入 parquet 文件的支持,同时自动捕获数据的结构。 -
Apache Kafka (
kafka.apache.org/
) 是一个流行的高吞吐量分布式消息系统。Spark Streaming 提供直接的 API 来支持从 Kafka 进行流数据摄取。 -
Alluxio (
alluxio.org/
),前身为 Tachyon,是一个以内存为中心的虚拟分布式存储系统,能够在集群之间以内存速度共享数据。它旨在成为大数据的事实上的存储统一层。Alluxio 位于计算框架(如 Spark)和存储系统(如 Amazon S3、HDFS 等)之间。 -
GraphFrames (
databricks.com/blog/2016/03/03/introducing-graphframes.html
) 是一个基于 Apache Spark 的图处理库,建立在 DataFrames API 之上。 -
Apache Kylin (
kylin.apache.org/
) 是一个分布式分析引擎,旨在提供 SQL 接口和多维分析(OLAP),支持 Hadoop 上的超大规模数据集。 -
Apache Sentry (
sentry.apache.org/
) 是一个系统,用于对存储在 Hadoop 集群中的数据和元数据执行细粒度的基于角色的授权。它在撰写本书时处于孵化阶段。 -
Apache Solr (
lucene.apache.org/solr/
) 是一个非常快速的搜索平台。查看这个 演示 了解如何将 Solr 与 Spark 集成。 -
TensorFlow (
www.tensorflow.org/
) 是一个机器学习库,广泛支持深度学习。查看这个 博客,了解如何与 Spark 一起使用。 -
Zeppelin (
zeppelin.incubator.apache.org/
) 是一个基于 Web 的笔记本,支持交互式数据分析。它在数据可视化章节中有介绍。
总结
在本章的最后,我们讨论了如何使用 Spark 构建现实世界的应用程序。我们讨论了包含技术性和非技术性方面的数据分析工作流的宏观视角。
参考文献
-
Spark Summit 网站包含了关于 Apache Spark 和相关项目的大量信息,来自已完成的活动。
-
与 Matei Zaharia 的访谈,由 KDnuggets 撰写。
-
来自 KDnuggets 的 为什么 Spark 在 2015 年达到了临界点,作者是 Matthew Mayo。
-
上线:准备你的第一个 Spark 生产部署是一个非常好的起点。
-
什么是 Scala? 来自 Scala 官网。
-
马丁·奥德斯基(Martin Odersky),Scala 的创始人,解释了为什么 Scala 将命令式编程和函数式编程融合在一起。